소스 검색

Initial anim studio game creator

bang 2 주 전
커밋
9d3c6590f8

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+__pycache__/
+.venv/
+.DS_Store
+out/
+uploads/
+local_config.json
+*.log

+ 112 - 0
README.md

@@ -0,0 +1,112 @@
+# Anim Studio — 游戏动画自动化 · 可视化资源库
+
+一个本地网站:填入大模型 key → 点「开始」批量生成动画资产 → 在网页里以**资源库**形式列出,并能**实时预览**每个角色/特效/动效动起来。
+
+## 启动(几乎零依赖)
+
+```bash
+cd anim_studio
+pip install Pillow          # 唯一必需的第三方库
+python server.py
+```
+
+浏览器打开 **http://127.0.0.1:7861**
+
+> 后端用 Python 标准库(http.server)写,不需要 Flask/Django;图像接口用标准库 urllib。所以装个 Pillow 就能跑。
+
+## 网站长什么样
+
+- **顶部「生成面板」**:填 Provider / API Key / Base URL / 模型 / 尺寸,编辑 `animation_manifest.json`,点 **▶ 开始生成**。日志实时显示,完成后资源库自动刷新。
+- **三个标签页 = 三个库**:
+  - **角色库**:每个角色一张卡片,图片按生成的关键帧**实时播放果冻抖动**(idle/win 可下拉切换),显示尺寸与文件数。
+  - **特效库**:每个粒子特效一个 **canvas 实时粒子预览**(金币雨、爆光等),显示模板与参数。
+  - **动效库**:每个 UI 动效一个 demo 方块,点「播放」实时演示(弹入、回弹、数字滚动…)。
+- **game 选择器**:切换不同游戏的资源库;「刷新」重新读取。
+- **📦 导出 Cocos 整合包**:把当前 game 一键打包成可直接拖进 Cocos 的整合包(见下方「一键导出 Cocos」)。
+- **🗑 删除该资源库**:把当前 game 从磁盘删除(`out/<game>` 整个文件夹,二次确认、不可恢复),用于清理测试产物。
+
+预览用的就是后端生成动画时的**同一套关键帧数据**,所以网页里看到的=Cocos 里跑出来的,不依赖外部播放器、版本零兼容问题。
+
+## 产物(同时落盘,可直接进 Cocos)
+
+```
+out/<game>/
+  library.json                  # 资源库索引(网站读它)
+  characters/<id>.json|.atlas|.png   # Spine 三件套(含程序化动画)
+  vfx/<id>.particle.json        # Cocos ParticleSystem2D 配置
+  ui/TweenPresets.ts            # UI 动效预设库(cc.tween)
+```
+
+进 Cocos:角色三件套作为 `sp.SkeletonData` 挂 `sp.Skeleton`;粒子配置喂 `ParticleSystem2D`;`TweenPresets.ts` 放进 scripts,`TweenPresets.play('scale_bounce', node).start()`。建议都放远程包 `page-main-res` 按需加载。
+
+## 一键导出 Cocos(零基础推荐)
+
+不想手动整理?在网页里选好 game,点 **📦 导出 Cocos 整合包**,会在下面生成一个可直接拖进 Cocos 的包:
+
+```
+out/<game>/cocos-pack/
+  把素材接进Cocos-零基础教程.md      # 从装软件到点播放的保姆教程
+  assets/
+    resources/characters/*.json|.atlas|.png
+    resources/vfx/*.json + particle.png   # 自动补一张柔光粒子贴图
+    scripts/JellyDemo.ts                  # 演示脚本(按本 game 资源自动生成)
+    scripts/ParticleConfig.ts             # 粒子配置 → ParticleSystem2D 加载器
+    scripts/TweenPresets.ts
+```
+
+新建一个 Cocos 空项目 → 把 `cocos-pack/assets/` 里的 `resources`、`scripts` 拖进项目 `assets` → 新建场景 → 把 `JellyDemo.ts` 挂到一个空节点上 → 点 ▶ 播放,全部角色排队果冻抖动、按钮可触发 WIN 和粒子特效。细节见包内教程。
+
+> 命令行也可单独导出:`python exporter.py <game>`
+
+## 从游戏需求自动生成 manifest
+
+当前后半段已经跑通:`animation_manifest.json` → 自动生图/动画 → 资源库预览 → Cocos 整合包。下一步建议补前半段:规范需求 + 参考图说明 → 文字模型生成游戏细则 → 生成 manifest → 校验 → 自动生图。
+
+详细流程见:[游戏定义到自动生图工作流.md](游戏定义到自动生图工作流.md)
+
+如果目标是 slot / 老虎机类电子游戏,先用玩法配置层约束用户选择,再生成 manifest:
+
+- [老虎机玩法配置工作流.md](老虎机玩法配置工作流.md)
+- [slot_game_config_template.json](slot_game_config_template.json)
+
+## Cocos 编辑器扩展(连拖文件都省)
+
+`cocos-extension/anim-studio-importer/` 是一个 Cocos Creator **3.8.x** 编辑器扩展,装一次后在编辑器里点菜单即可导入素材并布置场景,不用手动拖文件、建节点。
+
+安装:把 `anim-studio-importer` 文件夹放进 Cocos 扩展目录(项目内 `extensions/`,或全局 `~/.CocosCreator/extensions/`),在「扩展管理器」里启用。纯 JS、免编译。
+
+用法:
+
+1. 网站点 **📦 导出 Cocos 整合包** 得到 `cocos-pack/`;
+2. 编辑器菜单 **扩展 → Anim Studio → 「1. 导入 cocos-pack 资源」**,选该文件夹,自动拷进工程并刷新;
+3. 打开/新建一个含 Canvas 的场景,点 **「2. 在当前场景布置演示节点」**,自动建节点 + 挂 `JellyDemo`;
+4. 保存(Ctrl/Cmd+S)→ ▶ 播放。
+
+至此整链路为:**生成 → 导出整合包 → 编辑器点两下菜单 → 播放**,全程零代码。详见 `cocos-extension/安装说明.md`。
+
+> 「导入资源」是纯文件拷贝+刷新,最稳;「布置节点」为尽力自动,个别版本接口不符时会弹出手动兜底步骤(在 Canvas 下建空节点 + 拖 `JellyDemo.ts`,效果一致)。
+
+## 文件说明
+
+| 文件 | 作用 |
+|---|---|
+| `server.py` | 网站后端(标准库),**主入口** |
+| `web/index.html` | 可视化前端(资源库 + 实时预览) |
+| `pipeline.py` | 生成管线:manifest → 资产 + library.json |
+| `exporter.py` | 把某 game 打包成 Cocos 整合包(导出按钮 / 命令行调用) |
+| `cocos-extension/` | Cocos 3.8.x 编辑器扩展:菜单一键导入资源 + 布置演示场景 |
+| `providers.py` | 图像模型调用(OpenAI,可扩展) |
+| `spine_builder.py` | 角色图 → Spine 三件套 + 程序化抖动动画 |
+| `particle_builder.py` | 粒子模板 → Cocos 粒子配置 |
+| `tween_builder.py` | UI 动效预设 → TypeScript |
+| `app.py` | (可选)旧的 Gradio 极简表单界面,需额外 `pip install gradio` |
+
+## v1 能做 / 不做(重要)
+
+**能做**(纯 key + 一键):AI 生成透明角色图 → 自动套**单骨骼果冻 squash/stretch 抖动**(idle/win);粒子按模板+颜色批量出配置;UI 全套 tween 预设。
+
+**暂不做**(已留扩展结构):多部件骨骼(拆头/手分别绑骨做关节动作)、SAM 自动拆件、Blender 序列帧。这些在 `spine_builder.build_skeleton_json` 与《Spine量产工作流》文档里有扩展指引。
+
+## 兼容性
+
+生成的 `skeleton.json` 标 spine `4.0.00`,Cocos 3.8.x 一般直接加载;个别版本报错就把 json 拖进 Spine 编辑器再导出一次兜底。

+ 73 - 0
animation_manifest.json

@@ -0,0 +1,73 @@
+{
+  "game": "jelly-candy-slot",
+  "style": "cute 3D-rendered translucent jelly creature, glossy gummy texture, soft subsurface highlights, warm candy-land color palette, premium mobile game icon, centered, full body filling the frame with only a tiny transparent margin, clean crisp edges, output as PNG with TRUE alpha transparency, isolated subject, empty transparent background, NO background fill, NO checkerboard pattern, NO gray-and-white squares, no grid, no text, no shadow",
+
+  "_note": "尺寸只能用接口支持的三种:1024x1024 / 1024x1536(竖) / 1536x1024(横)。每个素材已按它在游戏里的区域选了最接近的比例;提示词统一要求:内容填满画面少留白、真透明、无棋盘格。",
+
+  "characters": [
+    { "id": "jelly_blue",   "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a round blue blueberry jelly creature mascot with big friendly eyes and a happy open smile, tiny arms raised, body fills the frame" },
+    { "id": "jelly_pink",   "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a pink strawberry jelly creature with sparkling eyes and a small green leaf on top, cheerful pose, body fills the frame" },
+    { "id": "jelly_green",  "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a green apple jelly creature with rosy cheeks and a cute grin, body fills the frame" },
+    { "id": "jelly_orange", "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "an orange citrus jelly creature with bright eyes and little stubby hands, body fills the frame" },
+    { "id": "jelly_purple", "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a purple grape jelly creature, slightly wobbly, shy smile, sparkles, body fills the frame" },
+    { "id": "jelly_lemon",  "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a yellow lemon jelly creature with a wide excited smile and star-shaped eyes, body fills the frame" },
+    { "id": "jelly_choco",  "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a glossy chocolate jelly creature with caramel swirls and warm happy eyes, body fills the frame" },
+    { "id": "jelly_rainbow","type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a rainbow gradient jelly creature, extra glossy and translucent, joyful expression, premium special character, body fills the frame" },
+    { "id": "symbol_coin",  "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a big shiny golden coin symbol with a smiling jelly face embossed in the center, thick gold rim, glossy slot game icon, coin fills the frame" },
+    { "id": "symbol_seven", "type": "spine", "animations": ["idle", "win"], "size": "1024x1024",
+      "prompt": "a glossy candy-styled lucky number seven, red jelly with thick gold outline, slot game icon, the 7 fills the frame" }
+  ],
+
+  "ui_art": [
+    { "id": "bg_main", "transparent": false, "size": "1024x1536",
+      "prompt": "vertical mobile slot game background, upper half bright blue sky with soft fluffy clouds, lower half pink strawberry-jelly candy terrain with scattered glossy fruit pieces and jelly cubes, dreamy candy land, no characters, no text, full-bleed illustration" },
+
+    { "id": "logo", "transparent": true, "size": "1536x1024",
+      "prompt": "horizontal glossy candy game logo lettering reading JELLY POP on two lines, top word red glossy jelly with thick gold outline, bottom word blue-to-purple gradient jelly with gold outline, playful bubble letters with sparkles, the lettering fills the frame width, true transparent background, no checkerboard" },
+
+    { "id": "reel_frame", "transparent": true, "size": "1024x1536",
+      "prompt": "a tall rounded-rectangle slot machine reel frame with a glowing light-blue neon glass border and soft rounded corners, the frame border runs right along the edges of the image, fully hollow transparent center, clean mobile game UI element, true transparent background, no checkerboard, no inner fill" },
+
+    { "id": "btn_spin", "transparent": true, "size": "1024x1024",
+      "prompt": "a large round glossy blue 3D spin button with two white circular spinning arrows centered, shiny candy style, soft outer glow, fills the frame, true transparent background, no checkerboard" },
+
+    { "id": "btn_minus", "transparent": true, "size": "1024x1024",
+      "prompt": "a round glossy purple 3D mobile game button with a bold white minus icon centered, candy style, fills the frame, true transparent background, no checkerboard" },
+    { "id": "btn_plus", "transparent": true, "size": "1024x1024",
+      "prompt": "a round glossy purple 3D mobile game button with a bold white plus icon centered, candy style, fills the frame, true transparent background, no checkerboard" },
+    { "id": "btn_turbo", "transparent": true, "size": "1024x1024",
+      "prompt": "a round glossy purple 3D mobile game button with a bold white lightning-bolt turbo icon centered, candy style, fills the frame, true transparent background, no checkerboard" },
+    { "id": "btn_auto", "transparent": true, "size": "1024x1024",
+      "prompt": "a round glossy purple 3D mobile game button with a bold white right-pointing play auto triangle icon centered, candy style, fills the frame, true transparent background, no checkerboard" },
+
+    { "id": "hud_pill", "transparent": true, "size": "1536x1024",
+      "prompt": "a wide horizontal rounded pill-shaped dark purple glossy UI panel for a mobile game HUD, the pill stretches across the full width of the frame, soft inner shadow and light rim, blank, no text, true transparent background, no checkerboard" },
+
+    { "id": "coin", "transparent": true, "size": "1024x1024",
+      "prompt": "a single shiny golden coin with a cute embossed jelly smile, thick gold rim, glossy 3D, fills the frame, true transparent background, no checkerboard" }
+  ],
+
+  "vfx": [
+    { "id": "coin_rain",    "type": "particle", "template": "rain",     "color": [255, 215, 0] },
+    { "id": "win_burst",    "type": "particle", "template": "burst",    "color": [255, 180, 80] },
+    { "id": "bigwin_glow",  "type": "particle", "template": "glow",     "color": [255, 240, 160] },
+    { "id": "confetti_pop", "type": "particle", "template": "confetti", "color": [255, 120, 200] }
+  ],
+
+  "ui": [
+    { "id": "spin_btn_press", "type": "tween", "preset": "scale_bounce" },
+    { "id": "reward_popup_in","type": "tween", "preset": "elastic_in" },
+    { "id": "panel_slide_in", "type": "tween", "preset": "fade_slide_in", "params": { "dy": 60 } },
+    { "id": "balance_roll",   "type": "tween", "preset": "number_roll", "params": { "from": 0, "to": 8888, "dur": 0.9 } },
+    { "id": "win_icon_pulse", "type": "tween", "preset": "pulse" }
+  ]
+}

+ 142 - 0
app.py

@@ -0,0 +1,142 @@
+"""Anim Studio —— 填入大模型 key,点 Start 即可批量生成游戏动画资产。
+
+启动:
+    pip install -r requirements.txt
+    python app.py
+然后浏览器打开终端里给出的本地地址。
+"""
+
+import json
+import os
+import traceback
+
+import gradio as gr
+
+import providers
+import spine_builder
+import particle_builder
+import tween_builder
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_MANIFEST_PATH = os.path.join(HERE, "animation_manifest.json")
+
+
+def _load_default_manifest():
+    try:
+        with open(DEFAULT_MANIFEST_PATH, "r", encoding="utf-8") as f:
+            return f.read()
+    except Exception:
+        return '{\n  "game": "demo",\n  "characters": [],\n  "vfx": [],\n  "ui": []\n}'
+
+
+def run_pipeline(provider, api_key, base_url, model, size, manifest_text, out_dir,
+                 progress=gr.Progress()):
+    logs = []
+    gallery = []
+
+    def log(msg):
+        logs.append(msg)
+        return "\n".join(logs)
+
+    try:
+        manifest = json.loads(manifest_text)
+    except Exception as e:
+        return f"❌ manifest 不是合法 JSON:{e}", []
+
+    out_dir = out_dir.strip() or os.path.join(HERE, "out")
+    game = manifest.get("game", "game")
+    base_out = os.path.join(out_dir, game)
+    chars_out = os.path.join(base_out, "characters")
+    vfx_out = os.path.join(base_out, "vfx")
+    ui_out = os.path.join(base_out, "ui")
+
+    style = manifest.get("style", "")
+    characters = manifest.get("characters", [])
+    vfx = manifest.get("vfx", [])
+    ui = manifest.get("ui", [])
+
+    log(f"输出目录: {base_out}")
+
+    # ---- A. Spine 角色(需要图像 API)----
+    if characters:
+        if not api_key.strip():
+            log("⚠️  未填 key,跳过角色生成(VFX / UI 仍会生成)。")
+        else:
+            for i, c in enumerate(characters):
+                cid = c.get("id", f"char_{i}")
+                progress((i + 1) / max(1, len(characters)), desc=f"生成角色 {cid}")
+                try:
+                    full_prompt = ", ".join(x for x in [
+                        c.get("prompt", ""), style,
+                        "single character, transparent background, no text, no shadow on ground"
+                    ] if x)
+                    log(f"🎨 [{cid}] 调用图像模型…")
+                    img = providers.generate(provider, full_prompt, api_key.strip(),
+                                             base_url.strip(), model.strip(), size)
+                    png = spine_builder.build_character(
+                        cid, img, chars_out, c.get("animations", ["idle"]))
+                    gallery.append(png)
+                    log(f"✅ [{cid}] 已生成 Spine 三件套 + 动画 {c.get('animations', ['idle'])}")
+                except Exception as e:
+                    log(f"❌ [{cid}] 失败: {e}")
+
+    # ---- B. 粒子 VFX(本地)----
+    for v in vfx:
+        vid = v.get("id", "vfx")
+        try:
+            p = particle_builder.build_particle(
+                vid, v.get("template", "burst"), v.get("color", [255, 255, 255]), vfx_out)
+            log(f"✨ [{vid}] 粒子配置已生成 ({v.get('template')})")
+        except Exception as e:
+            log(f"❌ [{vid}] 粒子失败: {e}")
+
+    # ---- C. UI Tween(本地)----
+    if ui:
+        used = [u.get("preset") for u in ui if u.get("preset")]
+        try:
+            path, missing = tween_builder.build_tweens(used, ui_out)
+            log(f"🎛  TweenPresets.ts 已生成 (用到: {used})")
+            if missing:
+                log(f"⚠️  manifest 引用了未定义预设: {missing}(已忽略,可在 tween_builder.py 添加)")
+        except Exception as e:
+            log(f"❌ Tween 失败: {e}")
+
+    log("\n—— 完成 ——")
+    log(f"把 {base_out} 下的资源导入 Cocos 工程(建议放 page-main-res 远程包)。")
+    log("Spine 三件套(.json/.atlas/.png) 直接作为 sp.SkeletonData 使用。")
+    return "\n".join(logs), gallery
+
+
+def build_ui():
+    with gr.Blocks(title="Anim Studio") as demo:
+        gr.Markdown("## 🍬 Anim Studio — 游戏动画自动化生成\n"
+                    "填入大模型 key → 编辑/沿用默认 manifest → 点 **开始**。\n"
+                    "角色用图像模型生成并自动套上 Spine 果冻抖动动画;粒子/UI 动效本地生成。")
+        with gr.Row():
+            with gr.Column(scale=1):
+                provider = gr.Dropdown(list(providers.PROVIDERS.keys()),
+                                       value=list(providers.PROVIDERS.keys())[0],
+                                       label="图像模型 Provider")
+                api_key = gr.Textbox(label="大模型 API Key", type="password",
+                                     placeholder="sk-...")
+                base_url = gr.Textbox(label="API Base URL",
+                                      value="https://api.openai.com/v1")
+                model = gr.Textbox(label="模型名", value="gpt-image-2")
+                size = gr.Dropdown(["1024x1024", "1024x1536", "1536x1024"],
+                                   value="1024x1024", label="生成尺寸")
+                out_dir = gr.Textbox(label="输出目录", value=os.path.join(HERE, "out"))
+                start = gr.Button("▶ 开始", variant="primary")
+            with gr.Column(scale=2):
+                manifest = gr.Code(label="animation_manifest.json", language="json",
+                                   value=_load_default_manifest(), lines=22)
+        log_box = gr.Textbox(label="运行日志", lines=14)
+        gallery = gr.Gallery(label="生成的角色图", columns=4, height=260)
+
+        start.click(run_pipeline,
+                    inputs=[provider, api_key, base_url, model, size, manifest, out_dir],
+                    outputs=[log_box, gallery])
+    return demo
+
+
+if __name__ == "__main__":
+    build_ui().launch()

+ 165 - 0
background_remover.py

@@ -0,0 +1,165 @@
+"""Aliyun background-removal integration.
+
+Aliyun Marketplace product cmapi00069648 exposes:
+  POST https://bgrem.market.alicloudapi.com/api/v1/bg-remove/submit
+  POST https://bgrem.market.alicloudapi.com/api/v1/bg-remove/query
+
+The remote service requires a public image URL. Generated images from the
+current gpt-image-2 gateway are usually b64-only, so the pipeline uses remote
+matting only when a public URL is available. There is intentionally no local
+fallback because local matting was not clean enough for production assets.
+"""
+
+import base64
+import http.client
+import io
+import json
+import time
+import uuid
+from urllib.parse import urlparse
+
+from PIL import Image, ImageFilter
+
+import config
+
+ALIYUN_APP_CODE = config.get("ALIYUN_BGREM_APP_CODE", "")
+HOST = "bgrem.market.alicloudapi.com"
+SUBMIT_PATH = "/api/v1/bg-remove/submit"
+QUERY_PATH = "/api/v1/bg-remove/query"
+
+
+def _json_post(path, payload, timeout=60):
+    if not ALIYUN_APP_CODE:
+        raise RuntimeError("missing ALIYUN_BGREM_APP_CODE")
+    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+    headers = {
+        "Content-Type": "application/json; charset=utf-8",
+        "Accept": "application/json",
+        "Authorization": f"APPCODE {ALIYUN_APP_CODE}",
+        "X-Ca-Nonce": uuid.uuid4().hex,
+    }
+    conn = http.client.HTTPSConnection(HOST, timeout=timeout)
+    try:
+        conn.request("POST", path, body=body, headers=headers)
+        res = conn.getresponse()
+        raw = res.read()
+        res_headers = dict(res.getheaders())
+    finally:
+        conn.close()
+    if res.status < 200 or res.status >= 300:
+        msg = raw.decode("utf-8", "ignore") or res_headers.get("X-Ca-Error-Message", "")
+        raise RuntimeError(f"HTTP {res.status}: {msg}")
+    if not raw:
+        raise RuntimeError("empty response")
+    return json.loads(raw.decode("utf-8"))
+
+
+def _download(url, timeout=120):
+    parsed = urlparse(url)
+    path = parsed.path or "/"
+    if parsed.query:
+        path += "?" + parsed.query
+    conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
+    conn = conn_cls(parsed.hostname, parsed.port, timeout=timeout)
+    try:
+        conn.request("GET", path)
+        res = conn.getresponse()
+        raw = res.read()
+    finally:
+        conn.close()
+    if res.status < 200 or res.status >= 300:
+        raise RuntimeError(f"download HTTP {res.status}")
+    return raw
+
+
+def _extract_result_key(data):
+    payload = data.get("data", data)
+    if isinstance(payload, str):
+        try:
+            payload = json.loads(payload)
+        except Exception:
+            return payload
+    if isinstance(payload, dict):
+        for key in ("result_key", "resultKey", "task_key", "taskKey"):
+            if payload.get(key):
+                return payload[key]
+        tasks = payload.get("task_list") or payload.get("taskList")
+        if isinstance(tasks, list) and tasks:
+            return _extract_result_key(tasks[0])
+    return None
+
+
+def _extract_result_url(data):
+    payload = data.get("data", data)
+    if isinstance(payload, str):
+        try:
+            payload = json.loads(payload)
+        except Exception:
+            return payload if payload.startswith(("http://", "https://")) else None
+    if isinstance(payload, dict):
+        results = payload.get("results")
+        if isinstance(results, list) and results:
+            return results[0]
+        for key in ("url", "result", "result_url", "resultUrl", "image_url", "imageUrl", "alpha", "alpha_url"):
+            val = payload.get(key)
+            if isinstance(val, str) and val:
+                return val
+        tasks = payload.get("task_list") or payload.get("taskList") or payload.get("result_list") or payload.get("resultList")
+        if isinstance(tasks, list) and tasks:
+            return _extract_result_url(tasks[0])
+    return None
+
+
+def _ensure_success(data):
+    if data.get("success") is False:
+        raise RuntimeError(data.get("message") or json.dumps(data, ensure_ascii=False)[:500])
+    code = data.get("code")
+    if code not in (None, 0, "0", 200, "200"):
+        raise RuntimeError(data.get("message") or json.dumps(data, ensure_ascii=False)[:500])
+
+
+def _remote_remove_by_url(image_url, model_type="general", log=None, label="image"):
+    submit_body = {"data": {"task_list": [{"model_type": model_type, "image": image_url}]}}
+    if log:
+        log(f"🧼 [{label}] 去背景:提交阿里云任务…")
+    submit = _json_post(SUBMIT_PATH, submit_body, timeout=60)
+    _ensure_success(submit)
+    result_key = _extract_result_key(submit)
+    if not result_key:
+        raise RuntimeError(f"提交接口未返回 result_key: {json.dumps(submit, ensure_ascii=False)[:500]}")
+    if log:
+        log(f"🧼 [{label}] 去背景:任务已提交 result_key={result_key}")
+
+    query_body = {"data": {"result_key": result_key}}
+    last = None
+    for i in range(40):
+        time.sleep(1.5 if i else 0.3)
+        data = _json_post(QUERY_PATH, query_body, timeout=60)
+        _ensure_success(data)
+        last = data
+        result_url = _extract_result_url(data)
+        if result_url:
+            if log:
+                log(f"🧼 [{label}] 去背景:任务完成,下载透明 PNG…")
+            return Image.open(io.BytesIO(_download(result_url))).convert("RGBA")
+        if log and i in (0, 3, 10, 20):
+            log(f"🧼 [{label}] 去背景:等待任务结果…")
+    raise RuntimeError(f"查询超时,最后返回: {json.dumps(last, ensure_ascii=False)[:500]}")
+
+
+def remove_background(img, log=None, label="image", enabled=True, image_url=None, model_type="general"):
+    if not enabled:
+        return img.convert("RGBA")
+    if image_url and image_url.startswith(("http://", "https://")):
+        try:
+            out = _remote_remove_by_url(image_url, model_type=model_type, log=log, label=label)
+            if log:
+                log(f"✅ [{label}] 远端去背景完成:透明 PNG {out.size[0]}×{out.size[1]}")
+            return out
+        except Exception as e:
+            if log:
+                log(f"⚠️  [{label}] 远端去背景失败,保留原图:{e}")
+    else:
+        if log:
+            log(f"ℹ️ [{label}] 去背景:当前图片没有公网 URL,跳过远端去背景并保留原图")
+    return img.convert("RGBA")

+ 268 - 0
build_preview.py

@@ -0,0 +1,268 @@
+"""build_preview.py —— 用清洗后的素材打一个单文件 HTML 可玩预览。
+
+把 out/<game>/ 里的角色 + UI 美术(已去棋盘格、真透明)按比例内嵌成
+base64,生成一个 index.html:手机竖屏布局、霓虹框包住果冻网格、可点 SPIN
+跑"同款聚 5 只就消除→连锁倍数"的玩法。纯前端、双击即玩,无需任何后端。
+
+用法: python build_preview.py [game] [输出html路径]
+"""
+import base64
+import io
+import json
+import os
+import sys
+from PIL import Image
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+GAME = sys.argv[1] if len(sys.argv) > 1 else "jelly-candy-slot"
+OUT = sys.argv[2] if len(sys.argv) > 2 else os.path.join(HERE, "out", GAME, f"JellyPop预览.html")
+BASE = os.path.join(HERE, "out", GAME)
+
+
+def b64(img, maxside, fmt="PNG"):
+    im = img.convert("RGBA")
+    w, h = im.size
+    s = min(1.0, maxside / max(w, h))
+    if s < 1.0:
+        im = im.resize((max(1, int(w * s)), max(1, int(h * s))), Image.LANCZOS)
+    buf = io.BytesIO()
+    im.save(buf, fmt)
+    return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
+
+
+def load(p):
+    return Image.open(p).convert("RGBA")
+
+
+lib = json.load(open(os.path.join(BASE, "library.json"), encoding="utf-8"))
+char_ids = [c["id"] for c in lib.get("characters", [])]
+
+# 内嵌资源(按用途控制尺寸,平衡清晰度与体积)
+A = {}
+for cid in char_ids:
+    A[cid] = b64(load(os.path.join(BASE, "characters", f"{cid}.png")), 190)
+ui = os.path.join(BASE, "ui_art")
+A["bg"] = b64(load(os.path.join(ui, "bg_main.png")), 820)
+A["logo"] = b64(load(os.path.join(ui, "logo.png")), 560)
+A["frame"] = b64(load(os.path.join(ui, "reel_frame.png")), 620)
+A["spin"] = b64(load(os.path.join(ui, "btn_spin.png")), 220)
+A["round"] = b64(load(os.path.join(ui, "btn_round.png")), 150)
+
+SYMS = json.dumps(char_ids, ensure_ascii=False)
+ASSETS = json.dumps(A, ensure_ascii=False)
+
+HTML = r"""<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
+<title>Jelly Pop · 果冻消消乐</title>
+<style>
+  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;margin:0;padding:0}
+  html,body{height:100%;background:#0e0a1c;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
+            display:flex;align-items:center;justify-content:center;overflow:hidden}
+  #phone{position:relative;height:min(100vh,calc(100vw*1.7778));aspect-ratio:9/16;
+         border-radius:26px;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.6);user-select:none}
+  #bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
+  #shade{position:absolute;inset:0;background:radial-gradient(120% 80% at 50% 0%,rgba(0,0,0,0) 40%,rgba(20,8,40,.45) 100%)}
+  .layer{position:absolute;left:0;right:0}
+  #logo{top:1.5%;height:13%;margin:auto;display:block}
+  #logo img{height:100%;display:block;margin:auto;filter:drop-shadow(0 4px 8px rgba(0,0,0,.35))}
+
+  #frameWrap{position:absolute;top:14.5%;height:55%;left:50%;transform:translateX(-50%);aspect-ratio:.737}
+  #frameImg{position:absolute;inset:0;width:100%;height:100%}
+  /* 霓虹框内孔 ≈ 左右各 15%、上下各 3% */
+  #panel{position:absolute;left:15%;right:15%;top:3%;bottom:3%;border-radius:7%/4%;
+         background:linear-gradient(180deg,rgba(255,255,255,.30),rgba(220,235,255,.16));
+         backdrop-filter:blur(2px)}
+  #grid{position:absolute;left:15%;right:15%;top:3%;bottom:3%;display:grid;
+        grid-template-columns:repeat(5,1fr);grid-template-rows:repeat(7,1fr);gap:1.5%;padding:2%}
+  .cell{position:relative;display:flex;align-items:center;justify-content:center}
+  .cell img{width:100%;height:100%;object-fit:contain;
+            filter:drop-shadow(0 2px 3px rgba(0,0,0,.28));transition:transform .12s ease}
+  .cell.pop img{animation:pop .42s ease both}
+  .cell.drop img{animation:drop .34s cubic-bezier(.2,1.3,.5,1) both}
+  @keyframes pop{0%{transform:scale(1)}35%{transform:scale(1.28) rotate(6deg)}
+                 70%{transform:scale(.2);opacity:.2}100%{transform:scale(.2);opacity:0}}
+  @keyframes drop{0%{transform:translateY(-60%) scale(.7);opacity:0}
+                  100%{transform:translateY(0) scale(1);opacity:1}}
+
+  #mult{position:absolute;top:33%;left:0;right:0;text-align:center;font-weight:900;
+        font-size:7vh;color:#ffd23f;text-shadow:0 3px 0 #c0392b,0 0 18px rgba(255,170,40,.7);
+        opacity:0;pointer-events:none;transform:scale(.5)}
+  #mult.show{animation:flash .9s ease both}
+  @keyframes flash{0%{opacity:0;transform:scale(.4)}25%{opacity:1;transform:scale(1.15)}
+                   70%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(1)}}
+
+  #hud{bottom:18.5%;height:8%;display:flex;gap:3%;padding:0 5%;margin:auto}
+  .pill{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
+        background:linear-gradient(180deg,rgba(86,62,150,.92),rgba(54,38,102,.95));
+        border:2px solid rgba(160,140,235,.9);border-radius:999px;color:#fff;
+        box-shadow:inset 0 2px 6px rgba(255,255,255,.18),0 4px 10px rgba(0,0,0,.3)}
+  .pill .k{font-size:1.5vh;letter-spacing:.1em;color:#cbd6ff;opacity:.95}
+  .pill .v{font-size:2.5vh;font-weight:800;line-height:1.1}
+
+  #ctrl{bottom:3%;height:14%;display:flex;align-items:center;justify-content:space-between;
+        padding:0 6%;margin:auto}
+  .cbtn{display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer}
+  .rb{position:relative;display:flex;align-items:center;justify-content:center;color:#fff;
+      font-weight:800;background:none;border:none}
+  .rb img{position:absolute;inset:0;width:100%;height:100%}
+  .rb span{position:relative;z-index:1;text-shadow:0 1px 2px rgba(0,0,0,.4)}
+  .small{width:8.5vh;height:8.5vh;font-size:3.2vh}
+  #spin{width:13.5vh;height:13.5vh;cursor:pointer;transition:transform .1s}
+  #spin:active{transform:scale(.92)}
+  #spin.spinning{animation:spin 1s linear infinite}
+  @keyframes spin{to{transform:rotate(360deg)}}
+  .lab{font-size:1.5vh;color:#fff;letter-spacing:.12em;opacity:.92;font-weight:700}
+  .on{color:#ffd23f;text-shadow:0 0 8px rgba(255,200,60,.8)}
+  #coins{position:absolute;inset:0;pointer-events:none;overflow:hidden}
+  .coin{position:absolute;width:5%;will-change:transform}
+  .hint{position:absolute;bottom:.4%;left:0;right:0;text-align:center;color:#fff;
+         font-size:1.4vh;opacity:.5}
+</style>
+</head>
+<body>
+<div id="phone">
+  <img id="bg">
+  <div id="shade"></div>
+  <div id="logo" class="layer"><img></div>
+
+  <div id="frameWrap">
+    <div id="panel"></div>
+    <div id="grid"></div>
+    <img id="frameImg">
+  </div>
+  <div id="mult"></div>
+
+  <div id="hud" class="layer">
+    <div class="pill"><div class="k">余额</div><div class="v" id="bal">5000</div></div>
+    <div class="pill"><div class="k">下注</div><div class="v" id="bet">50</div></div>
+    <div class="pill"><div class="k">本局赢</div><div class="v" id="win">0</div></div>
+  </div>
+
+  <div id="ctrl" class="layer">
+    <div class="cbtn"><button class="rb small" id="turbo"><img><span>⚡</span></button><div class="lab" id="turboLab">TURBO</div></div>
+    <div class="cbtn"><button class="rb small" id="minus"><img><span>−</span></button><div class="lab">BET</div></div>
+    <img id="spin">
+    <div class="cbtn"><button class="rb small" id="plus"><img><span>+</span></button><div class="lab">BET</div></div>
+    <div class="cbtn"><button class="rb small" id="auto"><img><span>▶</span></button><div class="lab" id="autoLab">AUTO</div></div>
+  </div>
+
+  <div id="coins"></div>
+  <div class="hint">点 ⟳ 旋转 · 同款果冻聚成一片就连锁消除,连锁越多倍数越高</div>
+</div>
+
+<script>
+const A = __ASSETS__;
+const SYMBOLS = __SYMBOLS__;
+const COLS = 5, ROWS = 7, MIN_MATCH = 7, MAX_CASCADE = 8, START = 5000;
+let bet = 50, balance = START, multiplier = 1, roundWin = 0, spinning = false;
+let cascade = 0, turbo = false, auto = false;
+const $ = s => document.querySelector(s);
+
+// 贴静态美术
+$('#bg').src = A.bg;
+$('#logo img').src = A.logo;
+$('#frameImg').src = A.frame;
+$('#spin').src = A.spin;
+document.querySelectorAll('.rb img').forEach(i => i.src = A.round);
+
+// 建网格
+const grid = $('#grid');
+const ids = [];        // ids[c][r]
+const cellEls = [];    // cellEls[c][r]
+const rand = () => SYMBOLS[Math.floor(Math.random()*SYMBOLS.length)];
+for (let r=0;r<ROWS;r++){
+  for (let c=0;c<COLS;c++){
+    const el = document.createElement('div'); el.className='cell';
+    const img = document.createElement('img');
+    el.appendChild(img); grid.appendChild(el);
+    (cellEls[c]=cellEls[c]||[])[r]=el; (ids[c]=ids[c]||[])[r]=null;
+  }
+}
+function setSym(c,r,id,drop){
+  ids[c][r]=id; const el=cellEls[c][r]; const img=el.querySelector('img');
+  img.src=A[id]; el.classList.remove('pop');
+  if(drop){ el.classList.remove('drop'); void el.offsetWidth; el.classList.add('drop'); }
+}
+for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++) setSym(c,r,rand(),false);
+
+const fmt = n => Math.floor(n).toLocaleString();
+function setHud(){ $('#bet').textContent=bet; $('#win').textContent=fmt(roundWin); }
+function animBalance(){
+  const cur=parseInt($('#bal').textContent.replace(/,/g,''))||0;
+  const step=(balance-cur)/8;
+  if(Math.abs(balance-cur)<1){ $('#bal').textContent=fmt(balance); return; }
+  $('#bal').textContent=fmt(cur+step); requestAnimationFrame(animBalance);
+}
+const T = () => turbo?0.45:1;   // turbo 缩短节奏
+
+function spin(){
+  if(spinning) return;
+  if(balance<bet){ flash('余额不足'); return; }
+  spinning=true; $('#spin').classList.add('spinning');
+  balance-=bet; animBalance(); multiplier=1; roundWin=0; cascade=0; setHud();
+  for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++) setSym(c,r,rand(),true);
+  setTimeout(resolve, 420*T());
+}
+
+function resolve(){
+  const count={};
+  for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++){const id=ids[c][r];count[id]=(count[id]||0)+1;}
+  const winners=Object.keys(count).filter(k=>count[k]>=MIN_MATCH);
+  if(!winners.length){ endRound(); return; }
+  let cleared=0;
+  for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++)
+    if(winners.includes(ids[c][r])){ cellEls[c][r].classList.add('pop'); cleared++; }
+  const pay=Math.floor(cleared*(bet/MIN_MATCH)*multiplier);
+  roundWin+=pay; balance+=pay; setHud(); animBalance();
+  flash('x'+multiplier+'  +'+pay); coinRain(Math.min(18,cleared));
+  cascade++;
+  setTimeout(()=>{
+    for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++)
+      if(winners.includes(ids[c][r])) setSym(c,r,rand(),true);
+    multiplier++;
+    if(cascade>=MAX_CASCADE){ endRound(); return; }   // 连锁封顶,确保收束
+    setTimeout(resolve, 360*T());
+  }, 520*T());
+}
+
+function endRound(){
+  if(roundWin>0) flash('总赢 +'+fmt(roundWin));
+  spinning=false; $('#spin').classList.remove('spinning');
+  if(auto && balance>=bet) setTimeout(spin, 600*T());
+}
+
+function flash(t){
+  const m=$('#mult'); m.textContent=t; m.classList.remove('show'); void m.offsetWidth; m.classList.add('show');
+}
+function coinRain(n){
+  const box=$('#coins');
+  for(let i=0;i<n;i++){
+    const c=document.createElement('img'); c.src=A['symbol_coin']||A[SYMBOLS[0]]; c.className='coin';
+    c.style.left=(10+Math.random()*80)+'%'; c.style.top='28%';
+    box.appendChild(c);
+    const dx=(Math.random()*2-1)*30, dur=0.9+Math.random()*0.6, rot=(Math.random()*2-1)*360;
+    c.animate([{transform:'translate(0,0) rotate(0)',opacity:1},
+               {transform:`translate(${dx}vw,70vh) rotate(${rot}deg)`,opacity:.2}],
+              {duration:dur*1000,easing:'cubic-bezier(.4,0,.7,1)'}).onfinish=()=>c.remove();
+  }
+}
+
+$('#spin').onclick=spin;
+$('#minus').onclick=()=>{ if(spinning)return; bet=Math.max(10,bet-10); setHud(); };
+$('#plus').onclick=()=>{ if(spinning)return; bet=Math.min(500,bet+10); setHud(); };
+$('#turbo').onclick=()=>{ turbo=!turbo; $('#turboLab').classList.toggle('on',turbo); };
+$('#auto').onclick=()=>{ auto=!auto; $('#autoLab').classList.toggle('on',auto); if(auto&&!spinning) spin(); };
+setHud();
+</script>
+</body>
+</html>
+"""
+
+html = HTML.replace("__ASSETS__", ASSETS).replace("__SYMBOLS__", SYMS)
+os.makedirs(os.path.dirname(OUT), exist_ok=True)
+with open(OUT, "w", encoding="utf-8") as f:
+    f.write(html)
+print("wrote", OUT, f"({len(html)//1024} KB)")

+ 121 - 0
cocos-extension/anim-studio-importer 2/main.js

@@ -0,0 +1,121 @@
+'use strict';
+// Anim Studio 导入器 —— Cocos Creator 3.8.x 编辑器扩展
+// 菜单:扩展 → Anim Studio → 1.导入 cocos-pack 资源 / 2.布置演示节点
+const fs = require('fs');
+const path = require('path');
+
+function projectAssets() {
+  return path.join(Editor.Project.path, 'assets');
+}
+
+// 递归拷贝(Node 16+ 自带 fs.cpSync;做个兜底)
+function copyDir(src, dst) {
+  if (fs.cpSync) { fs.cpSync(src, dst, { recursive: true }); return; }
+  fs.mkdirSync(dst, { recursive: true });
+  for (const name of fs.readdirSync(src)) {
+    const s = path.join(src, name), d = path.join(dst, name);
+    if (fs.statSync(s).isDirectory()) copyDir(s, d);
+    else fs.copyFileSync(s, d);
+  }
+}
+
+exports.methods = {
+  // ---------- 1. 导入资源 ----------
+  async importPack() {
+    let picked;
+    try {
+      picked = await Editor.Dialog.select({
+        title: '选择 Anim Studio 导出的 cocos-pack 文件夹',
+        path: Editor.Project.path,
+        type: 'directory',
+      });
+    } catch (e) {
+      return Editor.Dialog.error('Anim Studio', { detail: '打开文件选择框失败:' + e });
+    }
+    if (!picked || picked.canceled || !picked.filePaths || !picked.filePaths.length) return;
+
+    const pack = picked.filePaths[0];
+    // 兼容:用户既可能选 cocos-pack 本身,也可能选到里面的 assets
+    let srcAssets = path.join(pack, 'assets');
+    if (!fs.existsSync(srcAssets) && fs.existsSync(path.join(pack, 'resources'))) {
+      srcAssets = pack; // 选到了 assets 目录
+    }
+    if (!fs.existsSync(srcAssets)) {
+      return Editor.Dialog.error('Anim Studio', {
+        detail: '没找到 assets/(或 resources/)。请选 cocos-pack 文件夹,它里面应有 assets/resources 和 assets/scripts。',
+      });
+    }
+
+    const dstAssets = projectAssets();
+    const copied = [];
+    try {
+      for (const sub of ['resources', 'scripts']) {
+        const s = path.join(srcAssets, sub);
+        if (fs.existsSync(s)) { copyDir(s, path.join(dstAssets, sub)); copied.push(sub); }
+      }
+    } catch (e) {
+      return Editor.Dialog.error('Anim Studio', { detail: '拷贝文件失败:' + e });
+    }
+    if (!copied.length) {
+      return Editor.Dialog.error('Anim Studio', { detail: 'assets 里没有 resources / scripts 子目录。' });
+    }
+
+    // 让编辑器重新导入资源数据库
+    try { await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets'); } catch (e) {}
+
+    Editor.Dialog.info('Anim Studio', {
+      detail: '已导入:' + copied.join('、') +
+        '。\n等右下角导入进度条走完后,新建或打开一个场景,再点菜单「2. 在当前场景布置演示节点」。',
+      buttons: ['好的'],
+    });
+  },
+
+  // ---------- 2. 布置演示节点 ----------
+  async buildScene() {
+    // 先确认有打开的场景
+    let scene;
+    try { scene = await Editor.Message.request('scene', 'query-is-ready'); } catch (e) {}
+    try {
+      // 找一个 Canvas 作父节点(新建 2D 场景默认就有,名为 Canvas)
+      let parent = null;
+      try {
+        const tree = await Editor.Message.request('scene', 'query-node-tree');
+        parent = findCanvasUuid(tree);
+      } catch (e) {}
+
+      const opt = { name: 'AnimStudioDemo' };
+      if (parent) opt.parent = parent;
+      const nodeUuid = await Editor.Message.request('scene', 'create-node', opt);
+      const uuid = typeof nodeUuid === 'string' ? nodeUuid : (nodeUuid && nodeUuid.uuid);
+
+      await Editor.Message.request('scene', 'create-component', { uuid, component: 'JellyDemo' });
+
+      Editor.Dialog.info('Anim Studio', {
+        detail: '已创建节点「AnimStudioDemo」并挂上 JellyDemo 组件。\n' +
+          '按 Ctrl/Cmd+S 保存场景,再点上方 ▶ 播放即可。\n' +
+          (parent ? '' : '(没检测到 Canvas,如果画面空白,请确认场景里有 Canvas,并把该节点拖到 Canvas 下。)'),
+        buttons: ['完成'],
+      });
+    } catch (e) {
+      Editor.Dialog.error('Anim Studio', {
+        detail: '自动布置失败(多半是没有打开场景,或脚本还没导入完):' + e +
+          '\n\n手动兜底:新建/打开一个场景 → 在 Canvas 下右键新建空节点 → 把 assets/scripts/JellyDemo.ts 拖到它的属性检查器上 → 保存并播放。',
+      });
+    }
+  },
+};
+
+// 在节点树里找第一个名为 Canvas 的节点 uuid
+function findCanvasUuid(node) {
+  if (!node) return null;
+  if (Array.isArray(node)) {
+    for (const n of node) { const r = findCanvasUuid(n); if (r) return r; }
+    return null;
+  }
+  if (node.name === 'Canvas' && node.uuid) return node.uuid;
+  if (node.children) return findCanvasUuid(node.children);
+  return null;
+}
+
+exports.load = function () {};
+exports.unload = function () {};

+ 28 - 0
cocos-extension/anim-studio-importer 2/package.json

@@ -0,0 +1,28 @@
+{
+  "package_version": 2,
+  "name": "anim-studio-importer",
+  "version": "1.0.0",
+  "title": "Anim Studio 导入器",
+  "description": "一键把 Anim Studio 导出的 cocos-pack 资源导入当前工程,并在场景里布置演示节点。",
+  "author": "anim_studio",
+  "editor": ">=3.8.0",
+  "main": "./main.js",
+  "contributions": {
+    "menu": [
+      {
+        "path": "i18n:menu.extension/Anim Studio",
+        "label": "1. 导入 cocos-pack 资源",
+        "message": "import-pack"
+      },
+      {
+        "path": "i18n:menu.extension/Anim Studio",
+        "label": "2. 在当前场景布置演示节点",
+        "message": "build-scene"
+      }
+    ],
+    "messages": {
+      "import-pack": { "methods": ["importPack"] },
+      "build-scene": { "methods": ["buildScene"] }
+    }
+  }
+}

+ 121 - 0
cocos-extension/anim-studio-importer 3/main.js

@@ -0,0 +1,121 @@
+'use strict';
+// Anim Studio 导入器 —— Cocos Creator 3.8.x 编辑器扩展
+// 菜单:扩展 → Anim Studio → 1.导入 cocos-pack 资源 / 2.布置演示节点
+const fs = require('fs');
+const path = require('path');
+
+function projectAssets() {
+  return path.join(Editor.Project.path, 'assets');
+}
+
+// 递归拷贝(Node 16+ 自带 fs.cpSync;做个兜底)
+function copyDir(src, dst) {
+  if (fs.cpSync) { fs.cpSync(src, dst, { recursive: true }); return; }
+  fs.mkdirSync(dst, { recursive: true });
+  for (const name of fs.readdirSync(src)) {
+    const s = path.join(src, name), d = path.join(dst, name);
+    if (fs.statSync(s).isDirectory()) copyDir(s, d);
+    else fs.copyFileSync(s, d);
+  }
+}
+
+exports.methods = {
+  // ---------- 1. 导入资源 ----------
+  async importPack() {
+    let picked;
+    try {
+      picked = await Editor.Dialog.select({
+        title: '选择 Anim Studio 导出的 cocos-pack 文件夹',
+        path: Editor.Project.path,
+        type: 'directory',
+      });
+    } catch (e) {
+      return Editor.Dialog.error('Anim Studio', { detail: '打开文件选择框失败:' + e });
+    }
+    if (!picked || picked.canceled || !picked.filePaths || !picked.filePaths.length) return;
+
+    const pack = picked.filePaths[0];
+    // 兼容:用户既可能选 cocos-pack 本身,也可能选到里面的 assets
+    let srcAssets = path.join(pack, 'assets');
+    if (!fs.existsSync(srcAssets) && fs.existsSync(path.join(pack, 'resources'))) {
+      srcAssets = pack; // 选到了 assets 目录
+    }
+    if (!fs.existsSync(srcAssets)) {
+      return Editor.Dialog.error('Anim Studio', {
+        detail: '没找到 assets/(或 resources/)。请选 cocos-pack 文件夹,它里面应有 assets/resources 和 assets/scripts。',
+      });
+    }
+
+    const dstAssets = projectAssets();
+    const copied = [];
+    try {
+      for (const sub of ['resources', 'scripts']) {
+        const s = path.join(srcAssets, sub);
+        if (fs.existsSync(s)) { copyDir(s, path.join(dstAssets, sub)); copied.push(sub); }
+      }
+    } catch (e) {
+      return Editor.Dialog.error('Anim Studio', { detail: '拷贝文件失败:' + e });
+    }
+    if (!copied.length) {
+      return Editor.Dialog.error('Anim Studio', { detail: 'assets 里没有 resources / scripts 子目录。' });
+    }
+
+    // 让编辑器重新导入资源数据库
+    try { await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets'); } catch (e) {}
+
+    Editor.Dialog.info('Anim Studio', {
+      detail: '已导入:' + copied.join('、') +
+        '。\n等右下角导入进度条走完后,新建或打开一个场景,再点菜单「2. 在当前场景布置演示节点」。',
+      buttons: ['好的'],
+    });
+  },
+
+  // ---------- 2. 布置演示节点 ----------
+  async buildScene() {
+    // 先确认有打开的场景
+    let scene;
+    try { scene = await Editor.Message.request('scene', 'query-is-ready'); } catch (e) {}
+    try {
+      // 找一个 Canvas 作父节点(新建 2D 场景默认就有,名为 Canvas)
+      let parent = null;
+      try {
+        const tree = await Editor.Message.request('scene', 'query-node-tree');
+        parent = findCanvasUuid(tree);
+      } catch (e) {}
+
+      const opt = { name: 'AnimStudioDemo' };
+      if (parent) opt.parent = parent;
+      const nodeUuid = await Editor.Message.request('scene', 'create-node', opt);
+      const uuid = typeof nodeUuid === 'string' ? nodeUuid : (nodeUuid && nodeUuid.uuid);
+
+      await Editor.Message.request('scene', 'create-component', { uuid, component: 'JellyDemo' });
+
+      Editor.Dialog.info('Anim Studio', {
+        detail: '已创建节点「AnimStudioDemo」并挂上 JellyDemo 组件。\n' +
+          '按 Ctrl/Cmd+S 保存场景,再点上方 ▶ 播放即可。\n' +
+          (parent ? '' : '(没检测到 Canvas,如果画面空白,请确认场景里有 Canvas,并把该节点拖到 Canvas 下。)'),
+        buttons: ['完成'],
+      });
+    } catch (e) {
+      Editor.Dialog.error('Anim Studio', {
+        detail: '自动布置失败(多半是没有打开场景,或脚本还没导入完):' + e +
+          '\n\n手动兜底:新建/打开一个场景 → 在 Canvas 下右键新建空节点 → 把 assets/scripts/JellyDemo.ts 拖到它的属性检查器上 → 保存并播放。',
+      });
+    }
+  },
+};
+
+// 在节点树里找第一个名为 Canvas 的节点 uuid
+function findCanvasUuid(node) {
+  if (!node) return null;
+  if (Array.isArray(node)) {
+    for (const n of node) { const r = findCanvasUuid(n); if (r) return r; }
+    return null;
+  }
+  if (node.name === 'Canvas' && node.uuid) return node.uuid;
+  if (node.children) return findCanvasUuid(node.children);
+  return null;
+}
+
+exports.load = function () {};
+exports.unload = function () {};

+ 28 - 0
cocos-extension/anim-studio-importer 3/package.json

@@ -0,0 +1,28 @@
+{
+  "package_version": 2,
+  "name": "anim-studio-importer",
+  "version": "1.0.0",
+  "title": "Anim Studio 导入器",
+  "description": "一键把 Anim Studio 导出的 cocos-pack 资源导入当前工程,并在场景里布置演示节点。",
+  "author": "anim_studio",
+  "editor": ">=3.8.0",
+  "main": "./main.js",
+  "contributions": {
+    "menu": [
+      {
+        "path": "i18n:menu.extension/Anim Studio",
+        "label": "1. 导入 cocos-pack 资源",
+        "message": "import-pack"
+      },
+      {
+        "path": "i18n:menu.extension/Anim Studio",
+        "label": "2. 在当前场景布置演示节点",
+        "message": "build-scene"
+      }
+    ],
+    "messages": {
+      "import-pack": { "methods": ["importPack"] },
+      "build-scene": { "methods": ["buildScene"] }
+    }
+  }
+}

BIN
cocos-extension/anim-studio-importer.zip


+ 134 - 0
cocos-extension/anim-studio-importer/main.js

@@ -0,0 +1,134 @@
+'use strict';
+// Anim Studio 导入器 —— Cocos Creator 3.8.x 编辑器扩展
+// 菜单:扩展 → Anim Studio → 1.导入 cocos-pack 资源 / 2.布置演示节点
+const fs = require('fs');
+const path = require('path');
+
+function projectAssets() {
+  return path.join(Editor.Project.path, 'assets');
+}
+
+// 递归拷贝(Node 16+ 自带 fs.cpSync;做个兜底)
+function copyDir(src, dst) {
+  if (fs.cpSync) { fs.cpSync(src, dst, { recursive: true }); return; }
+  fs.mkdirSync(dst, { recursive: true });
+  for (const name of fs.readdirSync(src)) {
+    const s = path.join(src, name), d = path.join(dst, name);
+    if (fs.statSync(s).isDirectory()) copyDir(s, d);
+    else fs.copyFileSync(s, d);
+  }
+}
+
+exports.methods = {
+  // ---------- 1. 导入资源 ----------
+  async importPack() {
+    let picked;
+    try {
+      picked = await Editor.Dialog.select({
+        title: '选择 Anim Studio 导出的 cocos-pack 文件夹',
+        path: Editor.Project.path,
+        type: 'directory',
+      });
+    } catch (e) {
+      return Editor.Dialog.error('Anim Studio', { detail: '打开文件选择框失败:' + e });
+    }
+    if (!picked || picked.canceled || !picked.filePaths || !picked.filePaths.length) return;
+
+    const pack = picked.filePaths[0];
+    // 兼容:用户既可能选 cocos-pack 本身,也可能选到里面的 assets
+    let srcAssets = path.join(pack, 'assets');
+    if (!fs.existsSync(srcAssets) && fs.existsSync(path.join(pack, 'resources'))) {
+      srcAssets = pack; // 选到了 assets 目录
+    }
+    if (!fs.existsSync(srcAssets)) {
+      return Editor.Dialog.error('Anim Studio', {
+        detail: '没找到 assets/(或 resources/)。\n你要选的是 Anim Studio 导出的「cocos-pack」文件夹(里面有 assets/resources 和 assets/scripts),不是你的 Cocos 项目文件夹。',
+      });
+    }
+
+    const dstAssets = projectAssets();
+    // 防呆:选到了项目自己(src 与 dest 同根),会导致“自己拷给自己”
+    if (path.resolve(srcAssets) === path.resolve(dstAssets) ||
+        path.resolve(srcAssets).startsWith(path.resolve(dstAssets) + path.sep)) {
+      return Editor.Dialog.error('Anim Studio', {
+        detail: '你选到了当前项目自己的 assets 了。\n请改选 Anim Studio 网站「导出」生成的 cocos-pack 文件夹(通常在 out/<game>/cocos-pack/),它里面才有要导入的素材。',
+      });
+    }
+
+    const copied = [];
+    try {
+      for (const sub of ['resources', 'scripts']) {
+        const s = path.join(srcAssets, sub);
+        if (!fs.existsSync(s)) continue;
+        const d = path.join(dstAssets, sub);
+        // 再保险:逐个子目录也跳过 src===dest
+        if (path.resolve(s) === path.resolve(d)) continue;
+        copyDir(s, d);
+        copied.push(sub);
+      }
+    } catch (e) {
+      return Editor.Dialog.error('Anim Studio', { detail: '拷贝文件失败:' + e });
+    }
+    if (!copied.length) {
+      return Editor.Dialog.error('Anim Studio', { detail: 'assets 里没有 resources / scripts 子目录。' });
+    }
+
+    // 让编辑器重新导入资源数据库
+    try { await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets'); } catch (e) {}
+
+    Editor.Dialog.info('Anim Studio', {
+      detail: '已导入:' + copied.join('、') +
+        '。\n等右下角导入进度条走完后,新建或打开一个场景,再点菜单「2. 在当前场景布置演示节点」。',
+      buttons: ['好的'],
+    });
+  },
+
+  // ---------- 2. 布置演示节点 ----------
+  async buildScene() {
+    // 先确认有打开的场景
+    let scene;
+    try { scene = await Editor.Message.request('scene', 'query-is-ready'); } catch (e) {}
+    try {
+      // 找一个 Canvas 作父节点(新建 2D 场景默认就有,名为 Canvas)
+      let parent = null;
+      try {
+        const tree = await Editor.Message.request('scene', 'query-node-tree');
+        parent = findCanvasUuid(tree);
+      } catch (e) {}
+
+      const opt = { name: 'AnimStudioDemo' };
+      if (parent) opt.parent = parent;
+      const nodeUuid = await Editor.Message.request('scene', 'create-node', opt);
+      const uuid = typeof nodeUuid === 'string' ? nodeUuid : (nodeUuid && nodeUuid.uuid);
+
+      await Editor.Message.request('scene', 'create-component', { uuid, component: 'JellyDemo' });
+
+      Editor.Dialog.info('Anim Studio', {
+        detail: '已创建节点「AnimStudioDemo」并挂上 JellyDemo 组件。\n' +
+          '按 Ctrl/Cmd+S 保存场景,再点上方 ▶ 播放即可。\n' +
+          (parent ? '' : '(没检测到 Canvas,如果画面空白,请确认场景里有 Canvas,并把该节点拖到 Canvas 下。)'),
+        buttons: ['完成'],
+      });
+    } catch (e) {
+      Editor.Dialog.error('Anim Studio', {
+        detail: '自动布置失败(多半是没有打开场景,或脚本还没导入完):' + e +
+          '\n\n手动兜底:新建/打开一个场景 → 在 Canvas 下右键新建空节点 → 把 assets/scripts/JellyDemo.ts 拖到它的属性检查器上 → 保存并播放。',
+      });
+    }
+  },
+};
+
+// 在节点树里找第一个名为 Canvas 的节点 uuid
+function findCanvasUuid(node) {
+  if (!node) return null;
+  if (Array.isArray(node)) {
+    for (const n of node) { const r = findCanvasUuid(n); if (r) return r; }
+    return null;
+  }
+  if (node.name === 'Canvas' && node.uuid) return node.uuid;
+  if (node.children) return findCanvasUuid(node.children);
+  return null;
+}
+
+exports.load = function () {};
+exports.unload = function () {};

+ 28 - 0
cocos-extension/anim-studio-importer/package.json

@@ -0,0 +1,28 @@
+{
+  "package_version": 2,
+  "name": "anim-studio-importer",
+  "version": "1.0.0",
+  "title": "Anim Studio 导入器",
+  "description": "一键把 Anim Studio 导出的 cocos-pack 资源导入当前工程,并在场景里布置演示节点。",
+  "author": "anim_studio",
+  "editor": ">=3.8.0",
+  "main": "./main.js",
+  "contributions": {
+    "menu": [
+      {
+        "path": "i18n:menu.extension/Anim Studio",
+        "label": "1. 导入 cocos-pack 资源",
+        "message": "import-pack"
+      },
+      {
+        "path": "i18n:menu.extension/Anim Studio",
+        "label": "2. 在当前场景布置演示节点",
+        "message": "build-scene"
+      }
+    ],
+    "messages": {
+      "import-pack": { "methods": ["importPack"] },
+      "build-scene": { "methods": ["buildScene"] }
+    }
+  }
+}

+ 35 - 0
cocos-extension/安装说明.md

@@ -0,0 +1,35 @@
+# Anim Studio 导入器 · Cocos 扩展安装说明(3.8.5 / 3.8.6)
+
+装一次,以后在编辑器菜单点两下就能把素材导进任意工程并布置好演示场景,不用再手动拖文件。
+
+## 一、安装扩展(只做一次)
+
+把 `anim-studio-importer` 这个文件夹放进 Cocos 的扩展目录,二选一:
+
+- **只给当前项目用**:放到 `你的Cocos项目/extensions/` 下(没有 extensions 文件夹就新建一个)。
+- **所有项目通用**:放到全局扩展目录
+  - Windows:`C:\Users\你的用户名\.CocosCreator\extensions\`
+  - macOS:`~/.CocosCreator/extensions/`
+
+放好后,在 Cocos 顶部菜单 **扩展(Extension)→ 扩展管理器** 里找到「anim-studio-importer」,确认是**启用**状态(第一次可能要点一下启用 / 刷新)。
+
+> 本扩展用纯 JS 写的,不需要编译;放进去启用即可。
+
+## 二、怎么用(每次导入素材时)
+
+1. 先在 Anim Studio 网站点 **📦 导出 Cocos 整合包**,得到 `out/<game>/cocos-pack/`。
+2. 打开你的 Cocos 工程,顶部菜单 **扩展 → Anim Studio → 「1. 导入 cocos-pack 资源」**。
+3. 在弹出的文件框里选中那个 **`cocos-pack` 文件夹**,确定。等右下角导入进度条走完。
+4. 新建或打开一个场景(资源管理器右键 → 新建 Scene,双击打开;保证场景里有 **Canvas**)。
+5. 再点 **扩展 → Anim Studio → 「2. 在当前场景布置演示节点」**。
+6. 按 **Ctrl/Cmd + S** 保存场景,点正上方 **▶ 播放**:角色排队果冻抖动,按钮触发 WIN 和粒子特效。
+
+## 三、菜单点不动 / 没反应?
+
+- 扩展刚放进去要去「扩展管理器」**启用**或点**刷新**;个别情况重启一次编辑器。
+- 「布置演示节点」必须**先打开一个场景**才能用;没开场景会提示手动兜底步骤。
+- 如果第 5 步报「脚本还没导入完」,多等一会让资源导入跑完再点。
+
+## 四、自动布置失败时的手动兜底(很简单)
+
+新建/打开场景 → 在 **Canvas** 下右键「创建空节点」→ 把 `assets/scripts/JellyDemo.ts` 拖到该节点的属性检查器上 → 保存、播放。效果完全一样。

+ 362 - 0
cocos-jelly-pack/assets/scripts/SlotGame.ts

@@ -0,0 +1,362 @@
+// =============================================================
+//  SlotGame.ts —— 《果冻消消乐 · Jelly Pop》消除式老虎机(可玩原型)
+//  by anim_studio(按本 game 的角色自动生成)
+//
+//  小故事:糖果星球上,一群果冻好朋友最爱挤在一起蹦跳;
+//  只要同一种果冻聚到 5 只以上,它们就会开心地"啵"地一起消失,
+//  天上撒下金币,上面的果冻落下来补位,连锁越多、倍数越高。
+//
+//  用法:把本脚本挂到 Canvas 下的一个空节点上,点播放。
+// =============================================================
+import {
+  _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame, Sprite,
+  ParticleSystem2D, UITransform, Label, Graphics, Button, Color, Vec3,
+  view, EventTouch, tween, profiler,
+} from 'cc';
+import { applyParticleConfig } from './ParticleConfig';
+
+const { ccclass } = _decorator;
+
+const SYMBOLS = ["jelly_blue", "jelly_choco", "jelly_green", "jelly_lemon", "jelly_orange", "jelly_pink", "jelly_purple", "jelly_rainbow", "symbol_coin", "symbol_seven"];
+
+const COLS = 7, ROWS = 6;
+const MIN_MATCH = 5;          // 同种 ≥5 才消除
+const BET = 50;
+const START_BALANCE = 5000;
+
+@ccclass('SlotGame')
+export class SlotGame extends Component {
+  private dataMap: Record<string, sp.SkeletonData> = {};
+  private cells: sp.Skeleton[][] = [];     // [col][row]
+  private ids: string[][] = [];            // 当前每格符号
+
+  private cell = 90;
+  private gridX0 = 0;
+  private gridY0 = 0;
+
+  private spinning = false;
+  private balance = START_BALANCE;
+  private displayBalance = START_BALANCE;
+  private multiplier = 1;
+  private roundWin = 0;
+
+  private balanceLabel!: Label;
+  private betLabel!: Label;
+  private winLabel!: Label;
+  private multLabel!: Label;
+  private spinBtn!: Node;
+  private particleTex: SpriteFrame | null = null;
+  private frameCY = 0; private frameW = 0; private frameH = 0;
+  private logoNode!: Node;
+
+  onLoad() {
+    profiler && profiler.hideStats();
+    this.node.setPosition(0, 0, 0);
+    const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
+    ut.setAnchorPoint(0.5, 0.5);
+    const s = view.getVisibleSize();
+    ut.setContentSize(s.width, s.height);
+
+    this.computeLayout(s.width, s.height);
+    this.buildBackground(s.width, s.height);
+    this.buildHeader(s.width, s.height);
+    this.buildFrame();
+    this.buildHud(s.width, s.height);
+    this.buildControls(s.width, s.height);
+    this.loadArt(s.width, s.height);            // 有生成的美术就贴上来,替换代码画的
+
+    let left = SYMBOLS.length;
+    resources.load('vfx/particle/spriteFrame', SpriteFrame, (_e, sf) => { if (sf) this.particleTex = sf; });
+    SYMBOLS.forEach((id) => {
+      resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => {
+        if (!err) this.dataMap[id] = data;
+        if (--left === 0) this.buildGrid();
+      });
+    });
+  }
+
+  private computeLayout(W: number, H: number) {
+    const reelW = Math.min(W * 0.94, 720);
+    const reelTop = H * 0.28;            // 网格上沿(屏幕中上)
+    const reelMaxH = H * 0.42;
+    this.cell = Math.min(reelW / COLS, reelMaxH / ROWS);
+    this.gridX0 = -((COLS - 1) * this.cell) / 2;
+    this.gridY0 = reelTop - this.cell / 2;     // 第 0 行(顶行)中心
+  }
+
+  private cellPos(c: number, r: number): [number, number] {
+    return [this.gridX0 + c * this.cell, this.gridY0 - r * this.cell];
+  }
+
+  // ---------------- 背景 ----------------
+  private buildBackground(W: number, H: number) {
+    const n = new Node('bg'); n.parent = this.node; n.setSiblingIndex(0);
+    const g = n.addComponent(Graphics);
+    g.fillColor = new Color(120, 200, 255, 255);          // 上:天蓝
+    g.rect(-W / 2, 0, W, H / 2); g.fill();
+    g.fillColor = new Color(255, 150, 175, 255);          // 下:糖果粉
+    g.rect(-W / 2, -H / 2, W, H / 2); g.fill();
+    g.fillColor = new Color(150, 175, 230, 255);          // 中间过渡带
+    g.rect(-W / 2, -20, W, 40); g.fill();
+  }
+
+  // ---------------- 顶部 Logo + 故事副标题 ----------------
+  private buildHeader(W: number, H: number) {
+    const t = new Node('logo'); t.parent = this.node; t.setPosition(0, H / 2 - 70, 0);
+    this.logoNode = t;
+    const lab = t.addComponent(Label);
+    lab.string = '🍬 JELLY POP'; lab.fontSize = 46; lab.lineHeight = 50;
+    lab.color = new Color(255, 90, 70, 255);
+
+    const sub = new Node('sub'); sub.parent = this.node; sub.setPosition(0, H / 2 - 110, 0);
+    const sl = sub.addComponent(Label);
+    sl.string = '果冻消消乐 · 同款聚 5 只就啵啵消除'; sl.fontSize = 18; sl.lineHeight = 22;
+    sl.color = new Color(255, 255, 255, 230);
+  }
+
+  // ---------------- 卷轴发光框 ----------------
+  private buildFrame() {
+    const w = COLS * this.cell + 24, h = ROWS * this.cell + 24;
+    const cy = (this.gridY0 - (ROWS - 1) * this.cell / 2);
+    this.frameCY = cy; this.frameW = w; this.frameH = h;
+    const n = new Node('frame'); n.parent = this.node; n.setPosition(0, cy, 0);
+    const g = n.addComponent(Graphics);
+    g.fillColor = new Color(245, 244, 255, 235);          // 半透明白底
+    this.roundRect(g, -w / 2, -h / 2, w, h, 20); g.fill();
+    g.lineWidth = 8; g.strokeColor = new Color(120, 180, 255, 255);   // 蓝色发光边
+    this.roundRect(g, -w / 2, -h / 2, w, h, 20); g.stroke();
+    g.lineWidth = 3; g.strokeColor = new Color(255, 255, 255, 230);   // 内白线
+    this.roundRect(g, -w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 16); g.stroke();
+    // 竖向分隔线
+    g.lineWidth = 2; g.strokeColor = new Color(180, 190, 230, 90);
+    for (let c = 1; c < COLS; c++) {
+      const x = -w / 2 + 12 + c * this.cell;
+      g.moveTo(x, -h / 2 + 10); g.lineTo(x, h / 2 - 10);
+    }
+    g.stroke();
+  }
+
+  // ---------------- 底部 HUD 三胶囊 ----------------
+  private buildHud(W: number, H: number) {
+    const y = -H * 0.13;
+    const pw = Math.min(W * 0.3, 230), ph = 56, gap = 12;
+    this.balanceLabel = this.makePill(-(pw + gap), y, pw, ph, '💰', `${START_BALANCE}`);
+    this.betLabel = this.makePill(0, y, pw, ph, '🎯', `${BET}`);
+    this.winLabel = this.makePill(pw + gap, y, pw, ph, '👑', '0');
+
+    const m = new Node('mult'); m.parent = this.node; m.setPosition(0, -H * 0.13 + 70, 0);
+    this.multLabel = m.addComponent(Label);
+    this.multLabel.fontSize = 40; this.multLabel.color = new Color(255, 110, 70, 255);
+    this.multLabel.string = '';
+  }
+
+  private makePill(x: number, y: number, w: number, h: number, icon: string, val: string): Label {
+    const n = new Node('pill'); n.parent = this.node; n.setPosition(x, y, 0);
+    n.addComponent(UITransform).setContentSize(w, h);
+    const g = n.addComponent(Graphics);
+    g.fillColor = new Color(70, 50, 120, 235);
+    this.roundRect(g, -w / 2, -h / 2, w, h, h / 2); g.fill();
+    const il = new Node('i'); il.parent = n; il.setPosition(-w / 2 + 26, 0, 0);
+    const ic = il.addComponent(Label); ic.string = icon; ic.fontSize = 26;
+    const vl = new Node('v'); vl.parent = n; vl.setPosition(12, 0, 0);
+    const lab = vl.addComponent(Label);
+    lab.string = val; lab.fontSize = 26; lab.color = new Color(255, 255, 255, 255);
+    return lab;
+  }
+
+  // ---------------- 控制区:TURBO / − / SPIN / + / AUTO ----------------
+  private buildControls(W: number, H: number) {
+    const y = -H * 0.36;
+    this.spinBtn = this.makeRoundButton(0, y, 72, new Color(60, 120, 255, 255), '⟳', 44, () => this.spin());
+    this.makeRoundButton(-W * 0.34, y, 34, new Color(90, 70, 140, 255), '⚡', 24, () => {});
+    this.makeRoundButton(-W * 0.2, y, 34, new Color(90, 70, 140, 255), '−', 30, () => {});
+    this.makeRoundButton(W * 0.2, y, 34, new Color(90, 70, 140, 255), '+', 30, () => {});
+    this.makeRoundButton(W * 0.34, y, 34, new Color(90, 70, 140, 255), '▶', 24, () => {});
+    this.makeTextLabel(-W * 0.34, y - 48, 'TURBO', 16);
+    this.makeTextLabel(W * 0.34, y - 48, 'AUTO', 16);
+  }
+
+  private makeRoundButton(x: number, y: number, r: number, color: Color, glyph: string, fs: number, onClick: () => void): Node {
+    const node = new Node('btn'); node.parent = this.node; node.setPosition(x, y, 0);
+    node.addComponent(UITransform).setContentSize(r * 2, r * 2);
+    const g = node.addComponent(Graphics);
+    g.fillColor = color; g.circle(0, 0, r); g.fill();
+    g.lineWidth = 4; g.strokeColor = new Color(255, 255, 255, 235); g.circle(0, 0, r); g.stroke();
+    const gl = new Node('g'); gl.parent = node;
+    const lab = gl.addComponent(Label); lab.string = glyph; lab.fontSize = fs; lab.color = new Color(255, 255, 255, 255);
+    const btn = node.addComponent(Button);
+    btn.transition = Button.Transition.SCALE; btn.zoomScale = 0.9;
+    node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => onClick());
+    return node;
+  }
+
+  private makeTextLabel(x: number, y: number, text: string, fs: number) {
+    const n = new Node('tl'); n.parent = this.node; n.setPosition(x, y, 0);
+    const lab = n.addComponent(Label); lab.string = text; lab.fontSize = fs; lab.color = new Color(255, 255, 255, 230);
+  }
+
+  // ---------------- 套用生成的 UI 美术(有就贴,没有就保持代码画)----------------
+  private loadArt(W: number, H: number) {
+    // 背景整图:铺满全屏,压在最底层
+    this.tryArt('bg_main', (sf) => {
+      const n = this.spriteNode(sf, W, H); n.setPosition(0, 0, 0); n.setSiblingIndex(1);
+    });
+    // 卷轴外框:贴到网格区域,盖掉代码框
+    this.tryArt('reel_frame', (sf) => {
+      const n = this.spriteNode(sf, this.frameW + 40, this.frameH + 40);
+      n.setPosition(0, this.frameCY, 0);
+      const code = this.node.getChildByName('frame'); if (code) code.active = false;
+    });
+    // Logo:贴到顶部,隐藏文字 Logo
+    this.tryArt('logo', (sf) => {
+      const n = this.spriteNode(sf, 380, 200); n.setPosition(0, H / 2 - 110, 0);
+      if (this.logoNode) this.logoNode.active = false;
+    });
+    // SPIN 按钮贴图
+    this.tryArt('btn_spin', (sf) => {
+      const s = this.spinBtn.addComponent(Sprite); s.spriteFrame = sf; s.sizeMode = Sprite.SizeMode.CUSTOM;
+      const ut = this.spinBtn.getComponent(UITransform)!; ut.setContentSize(150, 150);
+      const g = this.spinBtn.getComponent(Graphics); if (g) g.clear();
+      const gl = this.spinBtn.getChildByName('g'); if (gl) gl.active = false;
+    });
+  }
+
+  private tryArt(id: string, cb: (sf: SpriteFrame) => void) {
+    resources.load(`ui_art/${id}/spriteFrame`, SpriteFrame, (err, sf) => { if (!err && sf) cb(sf); });
+  }
+
+  private spriteNode(sf: SpriteFrame, w: number, h: number): Node {
+    const n = new Node('art'); n.parent = this.node;
+    const ut = n.addComponent(UITransform); ut.setContentSize(w, h);
+    const s = n.addComponent(Sprite); s.spriteFrame = sf; s.sizeMode = Sprite.SizeMode.CUSTOM;
+    return n;
+  }
+
+  // ---------------- 网格 ----------------
+  private buildGrid() {
+    const tileScale = (this.cell * 0.82) / 1032;   // 角色原图约 1032px
+    for (let c = 0; c < COLS; c++) {
+      this.cells[c] = []; this.ids[c] = [];
+      for (let r = 0; r < ROWS; r++) {
+        const id = this.rand();
+        const [x, y] = this.cellPos(c, r);
+        const node = new Node(`cell_${c}_${r}`); node.parent = this.node;
+        node.setPosition(x, y, 0);
+        node.setScale(tileScale, tileScale, 1);
+        const sk = node.addComponent(sp.Skeleton);
+        sk.skeletonData = this.dataMap[id];
+        sk.premultipliedAlpha = false;
+        sk.setAnimation(0, 'idle', true);
+        this.cells[c][r] = sk; this.ids[c][r] = id;
+      }
+    }
+  }
+
+  private setSym(c: number, r: number, id: string) {
+    const sk = this.cells[c][r];
+    sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false;
+    sk.setAnimation(0, 'idle', true);
+    this.ids[c][r] = id;
+    const base = (this.cell * 0.82) / 1032;
+    sk.node.setScale(base * 1.3, base * 1.3, 1);
+    tween(sk.node).to(0.14, { scale: new Vec3(base, base, 1) }, { easing: 'backOut' }).start();
+  }
+
+  // ---------------- 玩法 ----------------
+  private spin() {
+    if (this.spinning) return;
+    if (this.balance < BET) { this.flashMult('余额不足'); return; }
+    this.balance -= BET; this.displayBalance = this.balance;
+    this.balanceLabel.string = `${Math.floor(this.displayBalance)}`;
+    this.multiplier = 1; this.roundWin = 0;
+    this.winLabel.string = '0'; this.multLabel.string = '';
+    this.spinning = true; this.setSpinEnabled(false);
+
+    // 全盘随机铺一次(带落入动画)
+    for (let c = 0; c < COLS; c++)
+      for (let r = 0; r < ROWS; r++) this.setSym(c, r, this.rand());
+
+    this.scheduleOnce(() => this.resolve(), 0.45);
+  }
+
+  // 消除-补位 连锁
+  private resolve() {
+    const count: Record<string, number> = {};
+    for (let c = 0; c < COLS; c++)
+      for (let r = 0; r < ROWS; r++) count[this.ids[c][r]] = (count[this.ids[c][r]] || 0) + 1;
+
+    const winSyms = Object.keys(count).filter((k) => count[k] >= MIN_MATCH);
+    if (winSyms.length === 0) {
+      this.endRound();
+      return;
+    }
+
+    let cleared = 0;
+    const winners: sp.Skeleton[] = [];
+    for (let c = 0; c < COLS; c++)
+      for (let r = 0; r < ROWS; r++)
+        if (winSyms.indexOf(this.ids[c][r]) >= 0) { winners.push(this.cells[c][r]); cleared++; }
+
+    const pay = Math.floor(cleared * (BET / MIN_MATCH) * this.multiplier);
+    this.roundWin += pay; this.balance += pay;
+    this.winLabel.string = `${this.roundWin}`;
+    this.flashMult(`x${this.multiplier}  +${pay}`);
+
+    winners.forEach((sk) => { sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0); });
+    this.playParticle('coin_rain');
+
+    // 短暂后:把中奖格替换成新符号(消除→补位),倍数 +1,继续连锁
+    this.scheduleOnce(() => {
+      for (let c = 0; c < COLS; c++)
+        for (let r = 0; r < ROWS; r++)
+          if (winSyms.indexOf(this.ids[c][r]) >= 0) this.setSym(c, r, this.rand());
+      this.multiplier += 1;
+      this.scheduleOnce(() => this.resolve(), 0.4);
+    }, 0.6);
+  }
+
+  private endRound() {
+    if (this.roundWin > 0) this.flashMult(`总赢 +${this.roundWin}`);
+    else this.multLabel.string = '';
+    this.spinning = false; this.setSpinEnabled(true);
+  }
+
+  private flashMult(text: string) {
+    this.multLabel.string = text;
+    const n = this.multLabel.node; n.setScale(0.6, 0.6, 1);
+    tween(n).to(0.3, { scale: new Vec3(1, 1, 1) }, { easing: 'elasticOut' }).start();
+  }
+
+  private playParticle(id: string) {
+    resources.load(`vfx/${id}`, JsonAsset, (err, asset) => {
+      if (err) return;
+      const n = new Node('p'); n.parent = this.node; n.setPosition(0, view.getVisibleSize().height / 2 - 200, 0);
+      const ps = n.addComponent(ParticleSystem2D);
+      applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame);
+      this.scheduleOnce(() => ps.stopSystem(), 1.2);
+      this.scheduleOnce(() => n.destroy(), 4);
+    });
+  }
+
+  update(dt: number) {
+    if (Math.abs(this.displayBalance - this.balance) > 0.5) {
+      this.displayBalance += (this.balance - this.displayBalance) * Math.min(1, dt * 6);
+      this.balanceLabel.string = `${Math.floor(this.displayBalance)}`;
+    }
+  }
+
+  private rand() { return SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]; }
+
+  private setSpinEnabled(on: boolean) {
+    const btn = this.spinBtn.getComponent(Button); if (btn) btn.interactable = on;
+    this.spinBtn.getChildByName('g')!.getComponent(Label)!.string = on ? '⟳' : '…';
+  }
+
+  private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) {
+    g.moveTo(x + r, y); g.lineTo(x + w - r, y);
+    g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false);
+    g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false);
+    g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false);
+    g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close();
+  }
+}

+ 26 - 0
config.py

@@ -0,0 +1,26 @@
+"""Local configuration helpers.
+
+Secrets are read from environment variables or local_config.json. The local
+config file is intentionally gitignored.
+"""
+
+import json
+import os
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+LOCAL_CONFIG_PATH = os.path.join(HERE, "local_config.json")
+
+
+def _local_config():
+    try:
+        with open(LOCAL_CONFIG_PATH, encoding="utf-8") as f:
+            return json.load(f)
+    except Exception:
+        return {}
+
+
+_CONFIG = _local_config()
+
+
+def get(name, default=""):
+    return os.environ.get(name) or _CONFIG.get(name) or default

+ 103 - 0
dechecker.py

@@ -0,0 +1,103 @@
+"""dechecker.py —— 兜底用:把 AI 图里画死的"透明棋盘格/背景"抠掉,恢复真透明。
+
+重要:现在生图管线已直接向接口请求真·透明 PNG(providers + pipeline),
+正常情况下根本不需要本模块。只有当上游模型仍把背景画成棋盘格时,才作为
+最后兜底自动运行。
+
+关键改进(避免戳洞):只移除"与图像边缘相连"的背景(四周的棋盘格 + 透明边),
+角色内部的白色高光/亮点因为被角色包住、不与边缘相连,会被完整保留——
+不会再像以前那样把白色切成缺口。
+"""
+import os
+import sys
+import numpy as np
+from PIL import Image, ImageFilter
+
+
+def _border_outside(bg):
+    """返回与图像四边相连的背景区域(布尔)。优先用 cv2,没有则用 numpy 形态学重建。"""
+    try:
+        import cv2
+        n, labels = cv2.connectedComponents(bg.astype(np.uint8), connectivity=4)
+        border = set(labels[0, :]).union(labels[-1, :], labels[:, 0], labels[:, -1])
+        border.discard(0)
+        return np.isin(labels, list(border))
+    except Exception:
+        # numpy 兜底:从四边种子在 bg 内反复膨胀直到稳定(形态学重建)
+        seed = np.zeros_like(bg)
+        seed[0, :] = bg[0, :]; seed[-1, :] = bg[-1, :]
+        seed[:, 0] = bg[:, 0]; seed[:, -1] = bg[:, -1]
+        for _ in range(4000):
+            grown = seed | np.roll(seed, 1, 0) | np.roll(seed, -1, 0) \
+                         | np.roll(seed, 1, 1) | np.roll(seed, -1, 1)
+            grown &= bg
+            if np.array_equal(grown, seed):
+                break
+            seed = grown
+        return seed
+
+
+def clean(im):
+    """返回 (新图RGBA, 抹掉的像素数)。无棋盘/已透明的图基本无操作。"""
+    img = im.convert("RGBA")
+    arr = np.asarray(img).copy()
+    al = arr[:, :, 3]
+    rgb = arr[:, :, :3].astype(np.int16)
+    bright = rgb.max(2)
+    sat = bright - rgb.min(2)
+    light = (al >= 200) & (bright > 185) & (sat < 28)   # 棋盘格的白/浅灰方块
+    trans = al < 40
+    if light.mean() < 0.04:                              # 几乎没有浅色块 → 不是棋盘
+        return img, 0
+    bg = light | trans
+    outside = _border_outside(bg)
+    # 只在"边缘相连的浅色块"占比可观时才动手(避免误伤纯色背景插画)
+    if (outside & light).mean() < 0.03:
+        return img, 0
+    # 向内吃 2px 去掉灰边
+    m = outside
+    for _ in range(2):
+        m = (m | np.roll(m, 1, 0) | np.roll(m, -1, 0)
+               | np.roll(m, 1, 1) | np.roll(m, -1, 1))
+    removed = int(((al > 10) & m).sum())
+    a = al.copy(); a[m] = 0
+    arr[:, :, 3] = a
+    arr[m, 0] = 0; arr[m, 1] = 0; arr[m, 2] = 0
+    out = Image.fromarray(arr.astype(np.uint8), "RGBA")
+    out.putalpha(out.getchannel("A").filter(ImageFilter.GaussianBlur(1.0)))   # 柔边
+    return out, removed
+
+
+def declutter_img(img, feather=False):
+    out, removed = clean(img)
+    return out.convert("RGBA"), removed > 0
+
+
+def declutter_file(src, dst=None):
+    out, removed = clean(Image.open(src))
+    if removed > 0:
+        out.save(dst or src)
+    return removed > 0
+
+
+def _walk(paths):
+    for p in paths:
+        if os.path.isdir(p):
+            for root, _d, files in os.walk(p):
+                for f in files:
+                    if f.lower().endswith(".png"):
+                        yield os.path.join(root, f)
+        elif p.lower().endswith(".png"):
+            yield p
+
+
+if __name__ == "__main__":
+    n = 0
+    for f in _walk(sys.argv[1:] or ["."]):
+        try:
+            if declutter_file(f):
+                n += 1
+                print("cleaned:", f)
+        except Exception as e:
+            print("skip", f, e)
+    print(f"done. {n} file(s) cleaned.")

+ 693 - 0
exporter.py

@@ -0,0 +1,693 @@
+"""把某个 game 的产物打包成「可直接拖进 Cocos 的整合包」。
+
+被 server.py 的 /api/export 调用,也可命令行单独跑:
+    python exporter.py jelly-candy-slot
+
+产物在  out/<game>/cocos-pack/ ,结构:
+    把素材接进Cocos-零基础教程.md
+    assets/
+      resources/characters/*.json|.atlas|.png
+      resources/vfx/*.json + particle.png
+      scripts/JellyDemo.ts  ParticleConfig.ts  TweenPresets.ts
+"""
+
+import json
+import math
+import os
+import shutil
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+
+def _symbol_fit_from_library(lib):
+    slot_config = lib.get("slot_config") or {}
+    symbol_cfg = (slot_config.get("layout") or {}).get("symbols") or {}
+    fill = float(symbol_cfg.get("targetCellFill", 0.92) or 0.92)
+    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 = {}
+    for item in lib.get("characters", []):
+        cid = item.get("id")
+        w = float(item.get("w") or 0)
+        h = float(item.get("h") or 0)
+        if not cid or w <= 0 or h <= 0:
+            continue
+        max_dim = max(w, h)
+        s = fill / max_dim
+        # Spine 原点在素材底部中心,节点要下移半个实际显示高度才会视觉居中。
+        oyf = (h * s) / 2.0
+        fits[cid] = {"s": round(s, 6), "oyf": round(oyf, 4)}
+    return fits, {"s": round(default_s, 6), "oyf": round(default_oyf, 4)}
+
+
+def _math_report_md(slot_config):
+    model = slot_config.get("mathModel") or {}
+    sim = model.get("simulation") or {}
+    rng = model.get("rng") or {}
+    reels = model.get("reelStrips") or []
+    lines = [
+        "# Slot Math Report",
+        "",
+        "This is a certification-candidate math package, not a third-party lab certificate.",
+        "",
+        "## Identity",
+        f"- Game: `{slot_config.get('game', {}).get('id', '')}`",
+        f"- Model hash: `{model.get('modelHash', '')}`",
+        f"- Status: `{model.get('status', '')}`",
+        "",
+        "## RTP Summary",
+        f"- Target RTP: `{sim.get('targetRtp', '')}`",
+        f"- Estimated RTP: `{sim.get('estimatedRtp', '')}`",
+        f"- Payout scale: `{model.get('payoutScale', '')}`",
+        f"- Hit frequency: `{sim.get('hitFrequency', '')}`",
+        f"- Std dev per spin: `{sim.get('stdDevPerSpin', '')}`",
+        f"- Base spins: `{sim.get('baseSpins', '')}`",
+        f"- Total resolved spins: `{sim.get('totalResolvedSpins', '')}`",
+        "",
+        "## RNG",
+        f"- Simulation RNG: `{rng.get('algorithm', '')}`",
+        f"- Seed: `{rng.get('seed', '')}`",
+        f"- Production requirement: `{rng.get('productionRequirement', '')}`",
+        "",
+        "## Reel Strips",
+    ]
+    for i, reel in enumerate(reels):
+        lines.append(f"- Reel {i + 1} ({len(reel)} stops): `{','.join(reel)}`")
+    lines.extend([
+        "",
+        "## Required Before Real Certification",
+        "- Freeze source code and generated math config.",
+        "- Replace prototype random calls with approved production RNG integration.",
+        "- Run lab-required long simulation volume and edge-case tests.",
+        "- Submit PAR sheet, reel strips, paytable, feature rules, RNG proof, and game binary.",
+    ])
+    return "\n".join(lines) + "\n"
+
+
+# ---------------------------------------------------------------- 粒子贴图
+def _write_particle_png(path):
+    """生成一张柔光圆点透明 PNG(粒子配置引用的 particle.png)。"""
+    try:
+        from PIL import Image
+    except ImportError:
+        return False
+    S = 64
+    im = Image.new("RGBA", (S, S), (0, 0, 0, 0))
+    px = im.load()
+    c = (S - 1) / 2.0
+    r = c
+    for y in range(S):
+        for x in range(S):
+            d = math.hypot(x - c, y - c) / r
+            a = max(0.0, 1.0 - d)
+            a = a * a
+            px[x, y] = (255, 255, 255, int(255 * a))
+    im.save(path)
+    return True
+
+
+# ---------------------------------------------------------------- 静态脚本:ParticleConfig.ts
+PARTICLE_CONFIG_TS = r"""// 自动生成 by anim_studio —— 把 *.json 粒子配置应用到 Cocos ParticleSystem2D
+// 这个文件你不用改。
+import { ParticleSystem2D, Color, Vec2, SpriteFrame, gfx } from 'cc';
+
+function toBlend(gl: number): number {
+  switch (gl) {
+    case 0:    return gfx.BlendFactor.ZERO;
+    case 1:    return gfx.BlendFactor.ONE;
+    case 768:  return gfx.BlendFactor.SRC_COLOR;
+    case 769:  return gfx.BlendFactor.ONE_MINUS_SRC_COLOR;
+    case 770:  return gfx.BlendFactor.SRC_ALPHA;
+    case 771:  return gfx.BlendFactor.ONE_MINUS_SRC_ALPHA;
+    case 772:  return gfx.BlendFactor.DST_ALPHA;
+    case 773:  return gfx.BlendFactor.ONE_MINUS_DST_ALPHA;
+    default:   return gfx.BlendFactor.ONE;
+  }
+}
+
+function col(arr: number[] | undefined, def: number[]): Color {
+  const a = arr && arr.length >= 3 ? arr : def;
+  return new Color(a[0] | 0, a[1] | 0, a[2] | 0, a.length > 3 ? (a[3] | 0) : 255);
+}
+
+export function applyParticleConfig(ps: ParticleSystem2D, c: any, spriteFrame: SpriteFrame) {
+  ps.spriteFrame = spriteFrame;
+  ps.emitterMode = ParticleSystem2D.EmitterMode.GRAVITY;
+
+  ps.duration       = c.duration       ?? -1;
+  ps.totalParticles = c.totalParticles ?? 200;
+  ps.emissionRate   = c.emissionRate   ?? 60;
+
+  ps.life    = c.life    ?? 2;
+  ps.lifeVar = c.lifeVar ?? 0;
+
+  ps.angle    = c.angle    ?? 90;
+  ps.angleVar = c.angleVar ?? 0;
+
+  ps.speed    = c.speed    ?? 100;
+  ps.speedVar = c.speedVar ?? 0;
+
+  ps.gravity = new Vec2(c.gravityX ?? 0, c.gravityY ?? 0);
+  ps.posVar  = new Vec2(c.posVarX ?? 0, c.posVarY ?? 0);
+
+  ps.startSize    = c.startSize    ?? 30;
+  ps.startSizeVar = c.startSizeVar ?? 0;
+  ps.endSize      = c.endSize      ?? -1;
+
+  ps.startSpin    = c.startSpin    ?? 0;
+  ps.startSpinVar = c.startSpinVar ?? 0;
+  ps.endSpin      = c.endSpin      ?? 0;
+  ps.endSpinVar   = c.endSpinVar   ?? 0;
+
+  ps.startColor    = col(c.startColor,    [255, 255, 255, 255]);
+  ps.startColorVar = col(c.startColorVar, [0, 0, 0, 0]);
+  ps.endColor      = col(c.endColor,      [255, 255, 255, 0]);
+  ps.endColorVar   = col(c.endColorVar,   [0, 0, 0, 0]);
+
+  if (c.blendFunc) {
+    ps.srcBlendFactor = toBlend(c.blendFunc.src);
+    ps.dstBlendFactor = toBlend(c.blendFunc.dst);
+  }
+
+  ps.resetSystem();
+}
+"""
+
+
+# ---------------------------------------------------------------- 模板脚本:JellyDemo.ts
+# __CHARACTERS__ / __VFX__ 会被替换成该 game 真实的资源 id 列表
+JELLY_DEMO_TS = r"""// =============================================================
+//  JellyDemo.ts —— 一键演示:加载全部角色 + 按钮触发 WIN / 特效
+//  by anim_studio(按本 game 的资源自动生成)
+//  用法:把本脚本拖到场景里一个空节点上,点播放。
+// =============================================================
+import {
+  _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame,
+  ParticleSystem2D, UITransform, Label, Graphics, Button, Color,
+  view, EventTouch,
+} from 'cc';
+import { applyParticleConfig } from './ParticleConfig';
+// 需要 UI 动效时:import { TweenPresets } from './TweenPresets';
+//   TweenPresets.play('scale_bounce', someNode).start();
+
+const { ccclass } = _decorator;
+
+const CHARACTERS = __CHARACTERS__;
+const VFX = __VFX__;
+
+const CHAR_SCALE = 0.11;
+
+@ccclass('JellyDemo')
+export class JellyDemo extends Component {
+  private skeletons: sp.Skeleton[] = [];
+  private particleTex: SpriteFrame | null = null;
+
+  onLoad() {
+    const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
+    const size = view.getVisibleSize();
+    ut.setContentSize(size.width, size.height);
+    this.loadParticleTexture(() => { this.buildCharacterGrid(); this.buildButtons(); });
+  }
+
+  private loadParticleTexture(done: () => void) {
+    resources.load('vfx/particle/spriteFrame', SpriteFrame, (err, sf) => {
+      if (!err) this.particleTex = sf;
+      else console.warn('[JellyDemo] 粒子贴图未加载到:', err);
+      done();
+    });
+  }
+
+  private buildCharacterGrid() {
+    const cols = 5, cellW = 175, cellH = 200;
+    const rows = Math.ceil(CHARACTERS.length / cols);
+    const startX = -((cols - 1) * cellW) / 2;
+    const startY = ((rows - 1) * cellH) / 2 + 40;
+    CHARACTERS.forEach((id, i) => {
+      resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => {
+        if (err) { console.error('[JellyDemo] 角色加载失败:', id, err); return; }
+        const node = new Node(id); node.parent = this.node;
+        const sk = node.addComponent(sp.Skeleton);
+        sk.skeletonData = data;
+        sk.premultipliedAlpha = false;
+        sk.setAnimation(0, 'idle', true);
+        node.setScale(CHAR_SCALE, CHAR_SCALE, 1);
+        const c = i % cols, r = Math.floor(i / cols);
+        node.setPosition(startX + c * cellW, startY - r * cellH, 0);
+        this.skeletons.push(sk);
+        this.makeLabel(this.node, id, startX + c * cellW, startY - r * cellH - 70, 16);
+      });
+    });
+  }
+
+  private buildButtons() {
+    const y = -view.getVisibleSize().height / 2 + 60;
+    this.makeButton('▶ 全部 WIN', -300, y, new Color(255, 120, 60, 255), () => {
+      this.skeletons.forEach((sk) => { sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0); });
+    });
+    VFX.forEach((id, i) => {
+      const x = -90 + i * 165;
+      this.makeButton(id, x, y, new Color(80, 150, 255, 255), () => this.playVfx(id));
+    });
+  }
+
+  private playVfx(id: string) {
+    resources.load(`vfx/${id}`, JsonAsset, (err, asset) => {
+      if (err) { console.error('[JellyDemo] 特效配置加载失败:', id, err); return; }
+      const node = new Node('vfx_' + id); node.parent = this.node; node.setPosition(0, 80, 0);
+      const ps = node.addComponent(ParticleSystem2D);
+      applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame);
+      this.scheduleOnce(() => { ps.stopSystem(); }, 1.5);
+      this.scheduleOnce(() => { node.destroy(); }, 5);
+    });
+  }
+
+  private makeButton(text: string, x: number, y: number, color: Color, onClick: () => void) {
+    const node = new Node('btn_' + text); node.parent = this.node; node.setPosition(x, y, 0);
+    const w = 150, h = 52;
+    const ut = node.addComponent(UITransform); ut.setContentSize(w, h);
+    const g = node.addComponent(Graphics); g.fillColor = color;
+    this.roundRect(g, -w / 2, -h / 2, w, h, 12); g.fill();
+    this.makeLabel(node, text, 0, 0, 22, new Color(255, 255, 255, 255));
+    const btn = node.addComponent(Button);
+    btn.transition = Button.Transition.SCALE; btn.zoomScale = 0.92;
+    node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => onClick());
+  }
+
+  private makeLabel(parent: Node, text: string, x: number, y: number, size: number, color?: Color) {
+    const n = new Node('label'); n.parent = parent; n.setPosition(x, y, 0);
+    const lab = n.addComponent(Label);
+    lab.string = text; lab.fontSize = size; lab.lineHeight = size + 2;
+    lab.color = color || new Color(60, 60, 60, 255);
+    const ps = parent.scale;
+    if (ps.x !== 0 && ps.x !== 1) n.setScale(1 / ps.x, 1 / ps.y, 1);
+  }
+
+  private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) {
+    g.moveTo(x + r, y); g.lineTo(x + w - r, y);
+    g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false);
+    g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false);
+    g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false);
+    g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close();
+  }
+}
+"""
+
+
+# ---------------------------------------------------------------- 模板脚本:SlotGame.ts
+# __SYMBOLS__ 会被替换成本 game 的符号列表
+SLOT_GAME_TS = r"""// =============================================================
+//  SlotGame.ts —— 果冻老虎机(可玩原型)by anim_studio(按本 game 资源生成)
+//  用法:把本脚本挂到 Canvas 下的一个空节点上,点播放。
+// =============================================================
+import {
+  _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame,
+  ParticleSystem2D, UITransform, Label, Graphics, Button, Color, Vec3,
+  view, EventTouch, tween, profiler,
+} from 'cc';
+import { applyParticleConfig } from './ParticleConfig';
+
+const { ccclass } = _decorator;
+
+const SYMBOLS = __SYMBOLS__;
+
+const COLS = 5, ROWS = 3;
+const CELL = 118;
+const SYM_SCALE = 0.085;
+const BET = 50;
+const START_BALANCE = 1000;
+
+@ccclass('SlotGame')
+export class SlotGame extends Component {
+  private dataMap: Record<string, sp.SkeletonData> = {};
+  private cells: sp.Skeleton[][] = [];
+  private cur: string[][] = [];
+  private finalGrid: string[][] = [];
+
+  private spinning = false;
+  private elapsed = 0;
+  private colStopAt: number[] = [];
+  private colStopped: boolean[] = [];
+  private swapTimer: number[] = [];
+
+  private balance = START_BALANCE;
+  private displayBalance = START_BALANCE;
+  private balanceLabel!: Label;
+  private winLabel!: Label;
+  private spinBtn!: Node;
+  private particleTex: SpriteFrame | null = null;
+
+  private gridY = 30;
+  private startX = -((COLS - 1) * CELL) / 2;
+
+  onLoad() {
+    profiler && profiler.hideStats();
+    this.node.setPosition(0, 0, 0);
+    const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
+    ut.setAnchorPoint(0.5, 0.5);
+    const s = view.getVisibleSize();
+    ut.setContentSize(s.width, s.height);
+    this.buildBackground(s.width, s.height);
+    this.buildPanel();
+    this.buildHud(s.width, s.height);
+    this.buildSpinButton(s.height);
+    let left = SYMBOLS.length;
+    resources.load('vfx/particle/spriteFrame', SpriteFrame, (_e, sf) => { if (sf) this.particleTex = sf; });
+    SYMBOLS.forEach((id) => {
+      resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => {
+        if (!err) this.dataMap[id] = data;
+        if (--left === 0) this.buildGrid();
+      });
+    });
+  }
+
+  private buildBackground(W: number, H: number) {
+    const n = new Node('bg'); n.parent = this.node; n.setSiblingIndex(0);
+    const g = n.addComponent(Graphics);
+    g.fillColor = new Color(40, 24, 64, 255);
+    g.rect(-W / 2, -H / 2, W, H); g.fill();
+    g.fillColor = new Color(58, 34, 92, 255);
+    g.rect(-W / 2, H / 2 - 90, W, 90); g.fill();
+    const t = new Node('title'); t.parent = this.node; t.setPosition(0, H / 2 - 45, 0);
+    const lab = t.addComponent(Label);
+    lab.string = '🍬 JELLY SLOT 🍬'; lab.fontSize = 34; lab.lineHeight = 38;
+    lab.color = new Color(255, 235, 160, 255);
+  }
+
+  private buildPanel() {
+    const w = COLS * CELL + 30, h = ROWS * CELL + 30;
+    const n = new Node('panel'); n.parent = this.node; n.setPosition(0, this.gridY, 0);
+    const g = n.addComponent(Graphics);
+    g.fillColor = new Color(24, 14, 38, 255);
+    this.roundRect(g, -w / 2, -h / 2, w, h, 18); g.fill();
+    const tile = CELL - 12;
+    for (let c = 0; c < COLS; c++) {
+      for (let r = 0; r < ROWS; r++) {
+        const cx = this.startX + c * CELL;
+        const cy = (1 - r) * CELL;
+        g.fillColor = new Color(247, 243, 252, 255);
+        this.roundRect(g, cx - tile / 2, cy - tile / 2, tile, tile, 14); g.fill();
+        g.lineWidth = 3; g.strokeColor = new Color(255, 205, 110, 200);
+        this.roundRect(g, cx - tile / 2, cy - tile / 2, tile, tile, 14); g.stroke();
+      }
+    }
+    g.lineWidth = 6; g.strokeColor = new Color(255, 200, 90, 255);
+    this.roundRect(g, -w / 2, -h / 2, w, h, 18); g.stroke();
+  }
+
+  private buildHud(W: number, H: number) {
+    const b = new Node('balance'); b.parent = this.node; b.setPosition(-W / 2 + 130, H / 2 - 130, 0);
+    this.balanceLabel = b.addComponent(Label);
+    this.balanceLabel.fontSize = 26; this.balanceLabel.color = new Color(180, 240, 255, 255);
+    const w = new Node('win'); w.parent = this.node; w.setPosition(0, H / 2 - 130, 0);
+    this.winLabel = w.addComponent(Label);
+    this.winLabel.fontSize = 30; this.winLabel.color = new Color(255, 220, 120, 255);
+    this.winLabel.string = '';
+    this.refreshBalance();
+  }
+
+  private refreshBalance() { this.balanceLabel.string = `💰 ${Math.floor(this.displayBalance)}`; }
+
+  private buildSpinButton(H: number) {
+    const node = new Node('spin'); node.parent = this.node;
+    node.setPosition(0, -H / 2 + 70, 0);
+    const w = 210, h = 64;
+    node.addComponent(UITransform).setContentSize(w, h);
+    const g = node.addComponent(Graphics);
+    g.fillColor = new Color(255, 110, 70, 255);
+    this.roundRect(g, -w / 2, -h / 2, w, h, 16); g.fill();
+    const ln = new Node('t'); ln.parent = node;
+    const lab = ln.addComponent(Label);
+    lab.string = '▶ SPIN'; lab.fontSize = 30; lab.color = new Color(255, 255, 255, 255);
+    const btn = node.addComponent(Button);
+    btn.transition = Button.Transition.SCALE; btn.zoomScale = 0.93;
+    node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => this.spin());
+    this.spinBtn = node;
+  }
+
+  private buildGrid() {
+    for (let c = 0; c < COLS; c++) {
+      this.cells[c] = []; this.cur[c] = []; this.finalGrid[c] = [];
+      for (let r = 0; r < ROWS; r++) {
+        const id = this.rand();
+        const node = new Node(`cell_${c}_${r}`); node.parent = this.node;
+        node.setPosition(this.startX + c * CELL, this.cellY(r), 0);
+        node.setScale(SYM_SCALE, SYM_SCALE, 1);
+        const sk = node.addComponent(sp.Skeleton);
+        sk.skeletonData = this.dataMap[id];
+        sk.premultipliedAlpha = false;
+        sk.setAnimation(0, 'idle', true);
+        this.cells[c][r] = sk; this.cur[c][r] = id;
+      }
+    }
+  }
+
+  private cellY(r: number) { return this.gridY + (1 - r) * CELL; }
+
+  private spin() {
+    if (this.spinning) return;
+    if (this.balance < BET) { this.flashWin('余额不足!'); return; }
+    this.balance -= BET; this.displayBalance = this.balance; this.refreshBalance();
+    this.winLabel.string = '';
+    this.decideResult();
+    this.spinning = true; this.elapsed = 0;
+    for (let c = 0; c < COLS; c++) { this.colStopped[c] = false; this.colStopAt[c] = 0.6 + c * 0.28; this.swapTimer[c] = 0; }
+    this.setSpinEnabled(false);
+  }
+
+  private decideResult() {
+    for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) this.finalGrid[c][r] = this.rand();
+    if (Math.random() < 0.4) {
+      const r = Math.floor(Math.random() * ROWS);
+      const sym = this.rand();
+      const len = Math.random() < 0.4 ? 4 : 3;
+      for (let c = 0; c < len && c < COLS; c++) this.finalGrid[c][r] = sym;
+    }
+  }
+
+  update(dt: number) {
+    if (!this.spinning) {
+      if (Math.abs(this.displayBalance - this.balance) > 0.5) {
+        this.displayBalance += (this.balance - this.displayBalance) * Math.min(1, dt * 6);
+        this.refreshBalance();
+      }
+      return;
+    }
+    this.elapsed += dt;
+    let allStopped = true;
+    for (let c = 0; c < COLS; c++) {
+      if (this.colStopped[c]) continue;
+      if (this.elapsed >= this.colStopAt[c]) {
+        for (let r = 0; r < ROWS; r++) this.setSymbol(c, r, this.finalGrid[c][r]);
+        this.colStopped[c] = true; this.popColumn(c);
+      } else {
+        this.swapTimer[c] -= dt;
+        if (this.swapTimer[c] <= 0) { this.swapTimer[c] = 0.06; for (let r = 0; r < ROWS; r++) this.setSymbol(c, r, this.rand()); }
+        allStopped = false;
+      }
+    }
+    if (allStopped) { this.spinning = false; this.evaluate(); this.setSpinEnabled(true); }
+  }
+
+  private setSymbol(c: number, r: number, id: string) {
+    const sk = this.cells[c][r];
+    if (this.cur[c][r] !== id) { sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false; this.cur[c][r] = id; }
+    sk.setAnimation(0, 'idle', true);
+  }
+
+  private popColumn(c: number) {
+    for (let r = 0; r < ROWS; r++) {
+      const n = this.cells[c][r].node;
+      n.setScale(SYM_SCALE * 1.25, SYM_SCALE * 1.25, 1);
+      tween(n).to(0.12, { scale: new Vec3(SYM_SCALE, SYM_SCALE, 1) }, { easing: 'backOut' }).start();
+    }
+  }
+
+  private evaluate() {
+    let totalWin = 0; const winners: sp.Skeleton[] = [];
+    for (let r = 0; r < ROWS; r++) {
+      const first = this.finalGrid[0][r]; let count = 1;
+      while (count < COLS && this.finalGrid[count][r] === first) count++;
+      if (count >= 3) {
+        totalWin += BET * (count - 2) * 2;
+        for (let c = 0; c < count; c++) winners.push(this.cells[c][r]);
+      }
+    }
+    if (totalWin > 0) {
+      this.balance += totalWin; this.flashWin(`WIN +${totalWin}`);
+      winners.forEach((sk) => { sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0); });
+      this.playCoinRain();
+    } else { this.winLabel.string = ''; }
+  }
+
+  private flashWin(text: string) {
+    this.winLabel.string = text;
+    const n = this.winLabel.node; n.setScale(0.6, 0.6, 1);
+    tween(n).to(0.35, { scale: new Vec3(1, 1, 1) }, { easing: 'elasticOut' }).start();
+  }
+
+  private playCoinRain() {
+    resources.load('vfx/coin_rain', JsonAsset, (err, asset) => {
+      if (err) return;
+      const n = new Node('coins'); n.parent = this.node; n.setPosition(0, 120, 0);
+      const ps = n.addComponent(ParticleSystem2D);
+      applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame);
+      this.scheduleOnce(() => ps.stopSystem(), 1.6);
+      this.scheduleOnce(() => n.destroy(), 5);
+    });
+  }
+
+  private rand() { return SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]; }
+
+  private setSpinEnabled(on: boolean) {
+    const btn = this.spinBtn.getComponent(Button); if (btn) btn.interactable = on;
+    this.spinBtn.getChildByName('t')!.getComponent(Label)!.string = on ? '▶ SPIN' : '转动中…';
+  }
+
+  private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) {
+    g.moveTo(x + r, y); g.lineTo(x + w - r, y);
+    g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false);
+    g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false);
+    g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false);
+    g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close();
+  }
+}
+"""
+
+
+# ---------------------------------------------------------------- 教程
+def _tutorial_md(game):
+    return f"""# 把「{game}」素材接进 Cocos —— 零基础教程
+
+这个文件夹是你点「导出 Cocos 整合包」自动生成的,可以直接进 Cocos Creator。
+全程不用写代码:装软件 → 新建项目 → 拖文件 → 挂一个脚本 → 点播放。
+
+## 包里有什么
+```
+cocos-pack/
+├─ 本教程.md
+└─ assets/                         ← 整个拖进 Cocos
+   ├─ resources/characters/  角色三件套(.json/.atlas/.png)
+   ├─ resources/vfx/         粒子配置 .json + particle.png
+   └─ scripts/  JellyDemo.ts / ParticleConfig.ts / TweenPresets.ts
+```
+> `resources` 文件夹名不能改,Cocos 靠它动态加载资源。
+
+## 步骤
+1. 装 **Cocos Creator 3.8.x**:https://www.cocos.com/creator-download
+2. 新建一个 **空项目(Empty / 2D)** 并打开。
+3. 把本包 `assets/` 里的 **resources、scripts 两个文件夹**一起拖进 Cocos 资源管理器的 `assets` 上,等导入进度条走完。
+4. 在 `assets` 右键 → 新建 Scene,双击打开;确保有个 **Canvas** 节点,在它下面新建一个**空节点**。
+5. 把 `scripts/JellyDemo.ts` 拖到那个空节点的属性检查器上(或「添加组件 → JellyDemo」)。
+6. 点正上方 **▶ 播放**:角色排队果冻抖动,底部按钮可触发「全部 WIN」和各粒子特效。
+
+## 常见问题
+- 角色没出来:多半素材没导完或 resources 被改名;确认能在资源管理器把角色展开成 SkeletonData。
+- 角色发黑/白边:脚本已设 premultipliedAlpha=false;仍有则在贴图设置关掉 PremultiplyAlpha 重新导入。
+- 太大/太小:改 `JellyDemo.ts` 顶部 `CHAR_SCALE`。
+- 特效看不见:确认 `resources/vfx/particle.png` 在。
+"""
+
+
+# ---------------------------------------------------------------- 主函数
+def export(game, out_root, log=print):
+    base = os.path.join(out_root, game)
+    if not os.path.isfile(os.path.join(base, "library.json")):
+        raise FileNotFoundError(f"找不到 game「{game}」的 library.json")
+
+    lib = json.load(open(os.path.join(base, "library.json"), encoding="utf-8"))
+    pack = os.path.join(base, "cocos-pack")
+    if os.path.exists(pack):
+        shutil.rmtree(pack)
+    res_ch = os.path.join(pack, "assets", "resources", "characters")
+    res_vfx = os.path.join(pack, "assets", "resources", "vfx")
+    scripts = os.path.join(pack, "assets", "scripts")
+    for d in (res_ch, res_vfx, scripts):
+        os.makedirs(d, exist_ok=True)
+
+    # 角色三件套
+    char_ids = []
+    src_ch = os.path.join(base, "characters")
+    if os.path.isdir(src_ch):
+        for f in sorted(os.listdir(src_ch)):
+            shutil.copy2(os.path.join(src_ch, f), os.path.join(res_ch, f))
+            if f.endswith(".json"):
+                char_ids.append(f[:-5])
+    log(f"📦 角色 {len(char_ids)} 个")
+
+    # 粒子配置:去掉 .particle 后缀,路径更干净
+    vfx_ids = []
+    src_vfx = os.path.join(base, "vfx")
+    if os.path.isdir(src_vfx):
+        for f in sorted(os.listdir(src_vfx)):
+            if f.endswith(".particle.json"):
+                vid = f[: -len(".particle.json")]
+                shutil.copy2(os.path.join(src_vfx, f), os.path.join(res_vfx, vid + ".json"))
+                vfx_ids.append(vid)
+            elif f.endswith(".json"):
+                shutil.copy2(os.path.join(src_vfx, f), os.path.join(res_vfx, f))
+                vfx_ids.append(f[:-5])
+    log(f"📦 特效 {len(vfx_ids)} 个")
+
+    # UI 美术(背景 / 外框 / 按钮 / Logo 等整图)
+    src_art = os.path.join(base, "ui_art")
+    art_ids = []
+    if os.path.isdir(src_art):
+        res_art = os.path.join(pack, "assets", "resources", "ui_art")
+        os.makedirs(res_art, exist_ok=True)
+        for f in sorted(os.listdir(src_art)):
+            if f.endswith(".png"):
+                shutil.copy2(os.path.join(src_art, f), os.path.join(res_art, f))
+                art_ids.append(f[:-4])
+        log(f"📦 UI 美术 {len(art_ids)} 张" if art_ids else "📦 无 UI 美术")
+
+    # 粒子贴图
+    if _write_particle_png(os.path.join(res_vfx, "particle.png")):
+        log("📦 粒子贴图 particle.png 已生成")
+    else:
+        log("⚠️  未装 Pillow,未能生成 particle.png(特效会发射但看不见)")
+
+    # 脚本
+    demo = (JELLY_DEMO_TS
+            .replace("__CHARACTERS__", json.dumps(char_ids, ensure_ascii=False))
+            .replace("__VFX__", json.dumps(vfx_ids, ensure_ascii=False)))
+    open(os.path.join(scripts, "JellyDemo.ts"), "w", encoding="utf-8").write(demo)
+    open(os.path.join(scripts, "ParticleConfig.ts"), "w", encoding="utf-8").write(PARTICLE_CONFIG_TS)
+    slot_tmpl = os.path.join(HERE, "templates", "SlotGame.ts")
+    slot_src = open(slot_tmpl, encoding="utf-8").read() if os.path.isfile(slot_tmpl) else SLOT_GAME_TS
+    slot_config = lib.get("slot_config") or {}
+    symbol_fit, symbol_fit_default = _symbol_fit_from_library(lib)
+    game_config_ts = (
+        "export const SLOT_CONFIG = "
+        + json.dumps(slot_config, ensure_ascii=False, indent=2)
+        + " as const;\n"
+        + "export const SYMBOLS = "
+        + json.dumps(char_ids, ensure_ascii=False, indent=2)
+        + " as const;\n"
+        + "export const SYMBOL_FIT = "
+        + json.dumps(symbol_fit, ensure_ascii=False, indent=2)
+        + " as const;\n"
+    )
+    open(os.path.join(scripts, "GameConfig.ts"), "w", encoding="utf-8").write(game_config_ts)
+    open(os.path.join(pack, "SlotMathReport.md"), "w", encoding="utf-8").write(_math_report_md(slot_config))
+    slot = (slot_src
+            .replace("__SYMBOLS__", json.dumps(char_ids, ensure_ascii=False))
+            .replace("__GAME_CONFIG__", json.dumps(slot_config, ensure_ascii=False))
+            .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)
+    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"))
+    log("📦 脚本 JellyDemo.ts / SlotGame.ts / GameConfig.ts / ParticleConfig.ts / TweenPresets.ts 已写入")
+
+    # 教程
+    open(os.path.join(pack, "把素材接进Cocos-零基础教程.md"), "w",
+         encoding="utf-8").write(_tutorial_md(game))
+    log(f"✅ 整合包完成:out/{game}/cocos-pack/")
+    return pack
+
+
+if __name__ == "__main__":
+    import sys
+    g = sys.argv[1] if len(sys.argv) > 1 else "game"
+    export(g, os.path.join(HERE, "out"))

+ 57 - 0
game_feedback_template.md

@@ -0,0 +1,57 @@
+# game_feedback_template.md
+
+用途:在输入基本游戏需求后,继续定义“游戏反馈”。这份模板会被文字模型转换为 `feedback_spec.json`,再约束 `animation_manifest.json` 里必须生成哪些角色动画、UI 动效、粒子特效和 UI 美术。
+
+## 1. 总体手感
+
+- 游戏反馈关键词:
+  - 例:果冻感、弹性、明亮、轻快、中奖很爽、点击即时
+- 反馈节奏:
+  - 点击反馈延迟:不超过 80ms
+  - 普通中奖反馈:约 0.6-1.2 秒
+  - 大奖反馈:约 1.8-3.5 秒
+- 反馈强度层级:
+  - micro:点击、切换、数值轻变
+  - normal_win:普通中奖
+  - big_win:大奖、高倍赔付
+  - bonus:免费旋转、特殊模式
+- 禁止事项:
+  - 例:不要长时间阻塞、不要暗黑闪烁、不要每次都满屏爆炸
+
+## 2. 玩家操作反馈
+
+| 操作 | 触发条件 | 视觉反馈 | 动效 | 特效 | 声音占位 | 时长 |
+|---|---|---|---|---|---|---:|
+| 点击 Spin | player_taps_spin_button | 按钮压缩、发光一下 | spin_btn_press | 无 | soft_pop | 180ms |
+| 加减下注 | player_taps_bet_plus_or_minus | 数字弹一下、HUD 高亮 | balance_roll / pulse | 无 | tick | 220ms |
+| 开启自动旋转 | auto_spin_enabled | 自动按钮持续呼吸 | pulse | 无 | toggle_on | -1 |
+| 打开设置 | open_settings | 面板从下滑入 | panel_slide_in | 无 | panel_open | 300ms |
+
+## 3. 玩法事件反馈
+
+| 事件 | 触发条件 | 视觉反馈 | 角色动画 | UI 动效 | 特效 | 声音占位 | 时长 | 是否阻塞点击 |
+|---|---|---|---|---|---|---|---:|---|
+| 开始转轮 | spin_started | 转轮框变暗、符号开始模糊滚动 | 无 | panel_slide_in | 无 | reel_start | 300ms | 是 |
+| 停轮无奖 | no_payout | 转轮恢复亮度、Spin 按钮恢复 | idle | scale_bounce | 无 | reel_stop | 300ms | 否 |
+| 普通中奖 | payout_greater_than_bet | 命中符号弹跳、中奖金额滚动 | win | scale_bounce / number_roll | win_burst | win_small | 900ms | 否 |
+| 大奖 | payout_at_least_10x_bet | 大奖弹窗、金币雨、角色庆祝 | win | reward_popup_in / number_roll / win_icon_pulse | coin_rain / bigwin_glow / confetti_pop | win_big | 2400ms | 是 |
+| 免费旋转 | free_spin_unlocked | 免费旋转徽章弹出、背景变亮 | win | elastic_in / pulse | confetti_pop / bigwin_glow | bonus_unlock | 1800ms | 是 |
+
+## 4. 状态与错误反馈
+
+| 状态 | 触发条件 | 视觉反馈 | 动效 | 特效 | 声音占位 | 时长 |
+|---|---|---|---|---|---|---:|
+| 余额不足 | spin_blocked_by_low_balance | 余额栏轻微抖动、Spin 变灰 | scale_bounce | 无 | error_soft | 320ms |
+| 网络等待 | request_pending | Spin 按钮 loading、HUD 降低亮度 | pulse | 无 | wait_loop | -1 |
+| 网络失败 | request_failed | 顶部提示条滑入、按钮恢复 | panel_slide_in | 无 | error_soft | 1200ms |
+
+## 5. 输出约束
+
+文字模型生成 `feedback_spec.json` 时必须遵守:
+
+- 每个反馈事件都有 `id`、`trigger`、`visual`、`duration_ms`。
+- 只使用当前可落地的 UI 动效:`scale_bounce`、`elastic_in`、`fade_slide_in`、`number_roll`、`pulse`。
+- 只使用当前可落地的粒子:`coin_rain`、`win_burst`、`bigwin_glow`、`confetti_pop`。
+- 角色动画只用当前支持的:`idle`、`win`。
+- 大奖和 bonus 可以阻塞输入,micro 和普通中奖尽量不阻塞。
+- 不要设计复杂镜头、3D 摄像机、真实物理破碎等当前 Cocos 2D 原型无法稳定实现的效果。

+ 7 - 0
local_config.example.json

@@ -0,0 +1,7 @@
+{
+  "ANIM_STUDIO_BASE_URL": "https://x.long.bid/v1",
+  "ANIM_STUDIO_API_KEY": "replace-with-your-key",
+  "ANIM_STUDIO_IMAGE_MODEL": "gpt-image-2",
+  "ANIM_STUDIO_TEXT_MODEL": "gpt-4o-mini",
+  "ALIYUN_BGREM_APP_CODE": "replace-with-your-app-code"
+}

+ 63 - 0
particle_builder.py

@@ -0,0 +1,63 @@
+"""按模板 + 参数生成 Cocos ParticleSystem2D 配置(JSON)。
+纯本地、无需 API。运行时把 JSON 喂给 ParticleSystem2D 即可(或转成 .plist)。
+"""
+
+import json
+import os
+
+# 几套基础粒子模板(可继续加)。坐标/数值为 Cocos ParticleSystem2D 字段。
+TEMPLATES = {
+    "rain": {
+        "emitterType": 0, "duration": -1, "emissionRate": 80, "life": 2.2, "lifeVar": 0.4,
+        "angle": 270, "angleVar": 10, "speed": 220, "speedVar": 40,
+        "gravityY": -300, "gravityX": 0,
+        "startSize": 36, "startSizeVar": 8, "endSize": 36,
+        "startSpin": 0, "startSpinVar": 180, "endSpin": 0, "endSpinVar": 180,
+        "posVarX": 360, "posVarY": 0,
+    },
+    "burst": {
+        "emitterType": 0, "duration": 0.4, "emissionRate": 600, "life": 0.8, "lifeVar": 0.2,
+        "angle": 90, "angleVar": 180, "speed": 320, "speedVar": 80,
+        "gravityY": -120, "gravityX": 0,
+        "startSize": 28, "startSizeVar": 10, "endSize": 0,
+        "posVarX": 8, "posVarY": 8,
+    },
+    "glow": {
+        "emitterType": 0, "duration": -1, "emissionRate": 24, "life": 1.4, "lifeVar": 0.3,
+        "angle": 90, "angleVar": 360, "speed": 30, "speedVar": 10,
+        "gravityY": 0, "gravityX": 0,
+        "startSize": 60, "startSizeVar": 14, "endSize": 0,
+        "posVarX": 30, "posVarY": 30,
+    },
+    "confetti": {
+        "emitterType": 0, "duration": 0.6, "emissionRate": 300, "life": 2.0, "lifeVar": 0.5,
+        "angle": 90, "angleVar": 60, "speed": 400, "speedVar": 120,
+        "gravityY": -260, "gravityX": 0,
+        "startSize": 24, "startSizeVar": 8, "endSize": 16,
+        "startSpin": 0, "startSpinVar": 360, "endSpin": 0, "endSpinVar": 360,
+        "posVarX": 20, "posVarY": 0,
+    },
+}
+
+
+def build_particle(vfx_id, template, color, out_dir, texture="particle.png"):
+    base = TEMPLATES.get(template)
+    if base is None:
+        raise ValueError(f"未知粒子模板: {template} (可选 {list(TEMPLATES)})")
+    r, g, b = (color + [255, 255, 255])[:3]
+    cfg = dict(base)
+    cfg.update({
+        "id": vfx_id,
+        "texture": texture,
+        "startColor": [r, g, b, 255],
+        "startColorVar": [0, 0, 0, 0],
+        "endColor": [r, g, b, 0],
+        "endColorVar": [0, 0, 0, 0],
+        "totalParticles": max(64, int(base["emissionRate"] * base["life"])),
+        "blendFunc": {"src": 770, "dst": 1},  # 加色,适合金币/光效
+    })
+    os.makedirs(out_dir, exist_ok=True)
+    path = os.path.join(out_dir, f"{vfx_id}.particle.json")
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(cfg, f, ensure_ascii=False, indent=2)
+    return path

+ 156 - 0
pipeline.py

@@ -0,0 +1,156 @@
+"""可被网站/命令行复用的生成管线。
+读 manifest -> 生成各类资产 -> 写 library.json(供网站可视化列出与预览)。
+"""
+
+import json
+import os
+
+import providers
+import spine_builder
+import particle_builder
+import tween_builder
+import background_remover
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+
+def run(manifest, out_root, creds=None, log=print):
+    """manifest: dict; out_root: 输出根目录; creds: {provider,api_key,base_url,model,size}
+    返回 (library_dict, base_out)。"""
+    creds = creds or {}
+    game = manifest.get("game", "game")
+    base_out = os.path.join(out_root, game)
+    chars_out = os.path.join(base_out, "characters")
+    vfx_out = os.path.join(base_out, "vfx")
+    ui_out = os.path.join(base_out, "ui")
+
+    style = manifest.get("style", "")
+    library = {
+        "game": game,
+        "slot_config": manifest.get("slot_config", {}),
+        "characters": [],
+        "vfx": [],
+        "ui": [],
+        "ui_art": [],
+    }
+    remove_bg_enabled = bool(creds.get("remove_bg", False))
+    total_steps = len(manifest.get("characters", [])) + len(manifest.get("ui_art", [])) + len(manifest.get("vfx", [])) + (1 if manifest.get("ui", []) else 0)
+    done_steps = 0
+
+    def progress(label):
+        nonlocal done_steps
+        done_steps += 1
+        log(f"进度 {done_steps}/{max(1, total_steps)} · {label}")
+
+    def transparent_prompt(extra):
+        return ", ".join([
+            extra,
+            "no shadow",
+            "no background",
+            "transparent background",
+            "isolated subject",
+            "clean PNG asset",
+            "输出 PNG,背景必须是真实 Alpha 透明通道,不是白底,不是棋盘格。主体居中,边缘干净,适合用于 App、网页和海报叠加",
+        ])
+
+    # ---- A. 角色(Spine)----
+    for i, c in enumerate(manifest.get("characters", [])):
+        cid = c.get("id", f"char_{i}")
+        anims = c.get("animations", ["idle"])
+        if not creds.get("api_key"):
+            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
+            library["characters"].append({
+                "id": cid,
+                "png": f"characters/{cid}.png",
+                "w": w, "h": h,
+                "animations": spine_builder.anim_data(anims),
+                "files": [f"characters/{cid}.json", f"characters/{cid}.atlas",
+                          f"characters/{cid}.png"],
+            })
+            log(f"✅ [{cid}] 完成 ({anims})")
+            progress(f"{cid}")
+        except Exception as e:
+            log(f"❌ [{cid}] 失败: {e}")
+            progress(f"{cid}")
+
+    # ---- A2. UI 美术(背景 / Logo / 卷轴框 / 按钮 等整图)----
+    ui_art_out = os.path.join(base_out, "ui_art")
+    for a in manifest.get("ui_art", []):
+        aid = a.get("id", "art")
+        if not creds.get("api_key"):
+            log(f"⚠️  未填 key,跳过 UI 美术 {aid}")
+            continue
+        try:
+            transparent = a.get("transparent", True)
+            extra = (transparent_prompt("single clean UI element, no text")
+                     if transparent
+                     else "full-bleed illustration, no text, no UI elements")
+            full_prompt = ", ".join(x for x in [a.get("prompt", ""), style if a.get("use_style") else "", extra] if x)
+            log(f"🖼  [{aid}] 生成 UI 美术…")
+            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"),
+                                     a.get("size", creds.get("size", "1024x1024")))
+            img = background_remover.remove_background(img, log=log, label=aid, enabled=remove_bg_enabled and transparent,
+                                                       image_url=img.info.get("source_url"))
+            os.makedirs(ui_art_out, exist_ok=True)
+            img.save(os.path.join(ui_art_out, f"{aid}.png"))
+            library["ui_art"].append({"id": aid, "file": f"ui_art/{aid}.png",
+                                      "w": img.width, "h": img.height,
+                                      "transparent": transparent})
+            log(f"✅ [{aid}] UI 美术完成")
+            progress(f"{aid}")
+        except Exception as e:
+            log(f"❌ [{aid}] UI 美术失败: {e}")
+            progress(f"{aid}")
+
+    # ---- B. 粒子 VFX ----
+    for v in manifest.get("vfx", []):
+        vid = v.get("id", "vfx")
+        try:
+            path = particle_builder.build_particle(
+                vid, v.get("template", "burst"), v.get("color", [255, 255, 255]), vfx_out)
+            cfg = json.load(open(path, encoding="utf-8"))
+            library["vfx"].append({"id": vid, "template": v.get("template"),
+                                   "file": f"vfx/{vid}.particle.json", "config": cfg})
+            log(f"✨ [{vid}] 粒子配置完成")
+            progress(f"{vid}")
+        except Exception as e:
+            log(f"❌ [{vid}] 粒子失败: {e}")
+            progress(f"{vid}")
+
+    # ---- C. UI Tween ----
+    ui = manifest.get("ui", [])
+    if ui:
+        used = [u.get("preset") for u in ui if u.get("preset")]
+        try:
+            tween_builder.build_tweens(used, ui_out)
+            for u in ui:
+                library["ui"].append({"id": u.get("id"), "preset": u.get("preset"),
+                                      "params": u.get("params", {})})
+            log(f"🎛  TweenPresets.ts 完成 ({used})")
+            progress("TweenPresets")
+        except Exception as e:
+            log(f"❌ Tween 失败: {e}")
+            progress("TweenPresets")
+
+    os.makedirs(base_out, exist_ok=True)
+    with open(os.path.join(base_out, "library.json"), "w", encoding="utf-8") as f:
+        json.dump(library, f, ensure_ascii=False, indent=2)
+    log("—— 完成 ——")
+    return library, base_out

+ 297 - 0
providers.py

@@ -0,0 +1,297 @@
+"""图像大模型调用层(仅用标准库 urllib,无第三方依赖)。
+
+内置 OpenAI 图像接口(gpt-image-1,支持透明背景)。
+接其他厂商:实现同签名函数加进 PROVIDERS 即可(兼容 OpenAI 协议的中转 Base URL 可直接用)。
+"""
+
+import base64
+import http.client
+import io
+import json
+import os
+import ssl
+import urllib.request
+from urllib.parse import urlparse
+from PIL import Image
+
+
+def _ssl_context():
+    """构造可信的 SSL 上下文。
+    优先用 certifi 的 CA 证书(解决 macOS python.org 找不到根证书的
+    CERTIFICATE_VERIFY_FAILED 问题);都没有时退回系统默认。
+    设环境变量 ANIM_STUDIO_INSECURE=1 可临时关闭校验(不推荐,仅排错用)。
+    """
+    if os.environ.get("ANIM_STUDIO_INSECURE") == "1":
+        ctx = ssl.create_default_context()
+        ctx.check_hostname = False
+        ctx.verify_mode = ssl.CERT_NONE
+        return ctx
+    try:
+        import certifi
+        return ssl.create_default_context(cafile=certifi.where())
+    except Exception:
+        return ssl.create_default_context()
+
+
+_SSL = _ssl_context()
+
+
+def _http_json(url, payload, headers, timeout):
+    data = json.dumps(payload).encode("utf-8")
+    parsed = urlparse(url)
+    path = parsed.path or "/"
+    if parsed.query:
+        path += "?" + parsed.query
+    conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
+    kwargs = {"timeout": timeout}
+    if parsed.scheme == "https":
+        kwargs["context"] = _SSL
+    conn = conn_cls(parsed.hostname, parsed.port, **kwargs)
+    try:
+        conn.request("POST", path, body=data, headers=headers)
+        res = conn.getresponse()
+        body = res.read()
+    finally:
+        conn.close()
+    if res.status < 200 or res.status >= 300:
+        raise urllib.error.HTTPError(url, res.status, res.reason, res.headers, io.BytesIO(body))
+    return json.loads(body.decode("utf-8"))
+
+
+def _http_bytes(url, timeout):
+    parsed = urlparse(url)
+    path = parsed.path or "/"
+    if parsed.query:
+        path += "?" + parsed.query
+    conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
+    kwargs = {"timeout": timeout}
+    if parsed.scheme == "https":
+        kwargs["context"] = _SSL
+    conn = conn_cls(parsed.hostname, parsed.port, **kwargs)
+    try:
+        conn.request("GET", path)
+        res = conn.getresponse()
+        body = res.read()
+    finally:
+        conn.close()
+    if res.status < 200 or res.status >= 300:
+        raise RuntimeError(f"下载生成图失败:{url} 返回 {res.status}")
+    return body
+
+
+def _short_json(data, limit=600):
+    try:
+        return json.dumps(data, ensure_ascii=False)[:limit]
+    except Exception:
+        return str(data)[:limit]
+
+
+def _read_http_error(error):
+    body = error.read().decode("utf-8", "ignore")[:1200]
+    return body
+
+
+def _should_retry_without_url_fields(status, body):
+    if status not in (400, 422):
+        return False
+    text = body.lower()
+    retry_markers = [
+        "response_format",
+        "store",
+        "background",
+        "output_format",
+        "unknown parameter",
+        "unsupported parameter",
+        "unrecognized",
+        "extra inputs",
+        "not permitted",
+    ]
+    return any(marker in text for marker in retry_markers)
+
+
+def _extract_image_item(data):
+    if isinstance(data, dict):
+        if data.get("data"):
+            items = data["data"]
+        elif data.get("images"):
+            items = data["images"]
+        else:
+            items = [data]
+    elif isinstance(data, list):
+        items = data
+    else:
+        items = []
+
+    if not items:
+        return {}
+    item = items[0]
+    if isinstance(item, str):
+        if item.startswith("http://") or item.startswith("https://"):
+            return {"url": item}
+        return {"b64_json": item}
+    if not isinstance(item, dict):
+        return {}
+
+    image_url = item.get("image_url")
+    if isinstance(image_url, dict) and image_url.get("url"):
+        return {"url": image_url["url"]}
+    return item
+
+
+def gen_image_openai(prompt, api_key, base_url="https://api.openai.com/v1",
+                     model="gpt-image-2", size="1024x1024", timeout=180):
+    """调用 OpenAI 兼容图像接口(含第三方中转),返回 PIL.Image (RGBA)。
+
+    兼容性处理:
+    - 仅对 gpt-image* 模型发送 background/output_format 等专属参数;其他模型
+      (dall-e-3 / flux / sora_image / gpt-4o-image 等中转常见模型)不发,避免被拒。
+    - 返回兼容 b64_json 或 url 两种格式。
+    """
+    url = base_url.rstrip("/") + "/images/generations"
+    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+    model_name = model.lower()
+    payload = {"model": model, "prompt": prompt, "size": size, "n": 1}
+    wants_transparent = "transparent background" in prompt.lower() or "no background" in prompt.lower()
+    if model_name == "gpt-image-2":
+        # x.long.bid exposes gpt-image-2, but its upstream only accepts the
+        # minimal image payload. Adding background/output_format causes 502.
+        pass
+    elif "gpt-image" in model_name:
+        # gpt-image-1 official-compatible endpoints can use these fields.
+        # If a gateway rejects them with 400/422, the retry path strips them.
+        if wants_transparent:
+            payload["background"] = "transparent"
+        payload["output_format"] = "png"
+    else:
+        # Same OpenAI-compatible image flow used by the content platform:
+        # ask compatible gateways for a downloadable URL, then still accept b64.
+        payload["response_format"] = "url"
+        payload["store"] = False
+        if wants_transparent:
+            payload["background"] = "transparent"
+            payload["output_format"] = "png"
+    try:
+        data = _http_json(url, payload, headers, timeout)
+    except urllib.error.HTTPError as e:
+        body = _read_http_error(e)
+        if _should_retry_without_url_fields(e.code, body):
+            fallback = dict(payload)
+            fallback.pop("response_format", None)
+            fallback.pop("store", None)
+            fallback.pop("background", None)
+            fallback.pop("output_format", None)
+            try:
+                data = _http_json(url, fallback, headers, timeout)
+            except urllib.error.HTTPError as e2:
+                body2 = _read_http_error(e2)
+                raise RuntimeError(f"图像接口 HTTP {e2.code}:{body2[:600]}\n"
+                                   f"(model={model}, url={url};已尝试移除 response_format/store 重试)")
+        else:
+            raise RuntimeError(f"图像接口 HTTP {e.code}:{body[:600]}\n"
+                               f"(model={model}, url={url})")
+    except urllib.error.URLError as e:
+        raise RuntimeError(f"连不上图像接口:{e}\n(检查 Base URL / 网络 / 证书;url={url})")
+
+    item = _extract_image_item(data)
+    source_url = None
+    if item.get("b64_json"):
+        raw = base64.b64decode(item["b64_json"])
+    elif item.get("base64"):
+        raw = base64.b64decode(item["base64"])
+    elif item.get("image"):
+        raw = base64.b64decode(item["image"])
+    elif item.get("url"):
+        source_url = item["url"]
+        raw = _http_bytes(source_url, timeout)
+    else:
+        raise RuntimeError(f"返回里既无 b64_json/base64/image 也无 url:{_short_json(data)}")
+    img = Image.open(io.BytesIO(raw)).convert("RGBA")
+    img = _auto_declutter(img)
+    img.info["source_url"] = source_url or ""
+    return img
+
+
+def _auto_declutter(img):
+    """统一出口:若模型把"透明背景"画成了灰白棋盘格(真像素、alpha 全 255),
+    自动抠成真透明。对本就透明 / 无棋盘格的图(角色、整张背景)是无操作,
+    所以可以无差别地套在每张生成图上;失败时绝不影响主流程。"""
+    try:
+        import dechecker
+        cleaned, changed = dechecker.declutter_img(img)
+        return cleaned if changed else img
+    except Exception:
+        return img
+
+
+PROVIDERS = {"OpenAI 兼容接口": gen_image_openai}
+
+
+def generate(provider, prompt, api_key, base_url, model, size):
+    fn = PROVIDERS.get(provider)
+    if fn is None:
+        raise ValueError(f"未知 provider: {provider}")
+    return fn(prompt, api_key=api_key, base_url=base_url, model=model, size=size)
+
+
+def chat_json_openai(messages, api_key, base_url="https://api.openai.com/v1",
+                     model="gpt-4o-mini", timeout=120):
+    url = base_url.rstrip("/") + "/chat/completions"
+    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+    payload = {
+        "model": model,
+        "messages": messages,
+        "temperature": 0.75,
+        "response_format": {"type": "json_object"},
+    }
+    try:
+        data = _http_json(url, payload, headers, timeout)
+    except urllib.error.HTTPError as e:
+        body = _read_http_error(e)
+        fallback = dict(payload)
+        fallback.pop("response_format", None)
+        try:
+            data = _http_json(url, fallback, headers, timeout)
+        except urllib.error.HTTPError as e2:
+            body2 = _read_http_error(e2)
+            raise RuntimeError(f"文字模型 HTTP {e2.code}:{body2[:600]}\n"
+                               f"(model={model}, url={url};已移除 response_format 重试)")
+    content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip()
+    if content.startswith("```"):
+        content = content.strip("`")
+        if content.lower().startswith("json"):
+            content = content[4:].strip()
+    try:
+        return json.loads(content)
+    except Exception as e:
+        raise RuntimeError(f"文字模型没有返回合法 JSON:{e};内容={content[:800]}")
+
+
+def analyze_reference_images(reference_urls=None, image_data_urls=None, api_key="",
+                             base_url="https://api.openai.com/v1", model="gpt-4o-mini",
+                             timeout=120):
+    """Use a vision-capable OpenAI-compatible chat model to summarize style refs.
+
+    `reference_urls` can include direct image URLs. `image_data_urls` are browser
+    FileReader data URLs from uploaded screenshots/images.
+    """
+    reference_urls = [x for x in (reference_urls or []) if x][:4]
+    image_data_urls = [x for x in (image_data_urls or []) if x][:4]
+    if not reference_urls and not image_data_urls:
+        return {}
+    content = [{
+        "type": "text",
+        "text": (
+            "分析这些参考图/截图的视觉方向,用于原创移动老虎机游戏美术生成。"
+            "只提炼风格,不要复刻具体 IP、logo、角色或版式。"
+            "返回 JSON:art_style, palette, materials, ui_shape_language, character_direction, "
+            "background_direction, avoid, image_prompt_style。"
+        ),
+    }]
+    for url in reference_urls:
+        content.append({"type": "image_url", "image_url": {"url": url}})
+    for data_url in image_data_urls:
+        content.append({"type": "image_url", "image_url": {"url": data_url}})
+    return chat_json_openai([
+        {"role": "system", "content": "你是游戏美术总监,擅长把参考图转成原创美术风格规范。只输出 JSON。"},
+        {"role": "user", "content": content},
+    ], api_key=api_key, base_url=base_url, model=model, timeout=timeout)

+ 7 - 0
requirements.txt

@@ -0,0 +1,7 @@
+# 必需的第三方库(网站后端用标准库,无需 web 框架)
+Pillow>=9.0.0
+numpy>=1.21.0       # dechecker 去棋盘格用(恢复 AI 图被画死的"透明背景")
+certifi>=2023.0.0   # 提供 CA 证书,解决 macOS SSL CERTIFICATE_VERIFY_FAILED
+
+# 可选:python app.py 那个极简表单界面才需要;网站(server.py)不需要
+# gradio>=4.0.0

+ 9 - 0
run.command

@@ -0,0 +1,9 @@
+#!/bin/bash
+# macOS 双击启动:装依赖 + 起网站 + 自动开浏览器
+cd "$(dirname "$0")" || exit 1
+echo "==> 检查依赖 (Pillow / certifi)…"
+python3 -c "import PIL" 2>/dev/null || python3 -m pip install --user --quiet Pillow
+python3 -c "import certifi" 2>/dev/null || python3 -m pip install --user --quiet certifi
+echo "==> 启动 Anim Studio 网站…"
+( sleep 2; open "http://127.0.0.1:7861" ) &
+python3 server.py

+ 10 - 0
run_windows.bat

@@ -0,0 +1,10 @@
+@echo off
+REM Windows 双击启动:装依赖 + 起网站 + 自动开浏览器
+cd /d "%~dp0"
+echo ==> 检查依赖 (Pillow / certifi)...
+python -c "import PIL" 2>nul || python -m pip install --quiet Pillow
+python -c "import certifi" 2>nul || python -m pip install --quiet certifi
+echo ==> 启动 Anim Studio 网站...
+start "" "http://127.0.0.1:7861"
+python server.py
+pause

+ 471 - 0
server.py

@@ -0,0 +1,471 @@
+"""Anim Studio 网站后端(仅用 Python 标准库,无 web 框架依赖)。
+
+启动:
+    pip install Pillow        # 唯一必需第三方库
+    python server.py
+浏览器打开 http://127.0.0.1:7861
+
+路由:
+  GET  /                      可视化网站
+  GET  /api/manifest          默认 manifest
+  GET  /api/games             已生成的 game 列表
+  GET  /api/library?game=..   某 game 的资源/动画库
+  GET  /assets/<game>/<path>  资源文件
+  POST /api/generate          运行生成管线
+  POST /api/export            把某 game 打包成 Cocos 整合包
+  POST /api/delete            删除某 game 的资源库
+"""
+
+import json
+import mimetypes
+import os
+import posixpath
+import shutil
+import threading
+import time
+import traceback
+import uuid
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import urlparse, parse_qs, unquote
+
+import pipeline
+import exporter
+import slot_workflow
+import providers
+import config
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT_ROOT = os.path.join(HERE, "out")
+WEB_DIR = os.path.join(HERE, "web")
+DEFAULT_MANIFEST = os.path.join(HERE, "animation_manifest.json")
+PORT = 7861
+DEFAULT_BASE_URL = config.get("ANIM_STUDIO_BASE_URL", "https://x.long.bid/v1")
+DEFAULT_API_KEY = config.get("ANIM_STUDIO_API_KEY", "")
+DEFAULT_IMAGE_MODEL = config.get("ANIM_STUDIO_IMAGE_MODEL", "gpt-image-2")
+DEFAULT_TEXT_MODEL = config.get("ANIM_STUDIO_TEXT_MODEL", "gpt-4o-mini")
+JOBS = {}
+JOBS_LOCK = threading.Lock()
+
+
+def list_games():
+    if not os.path.isdir(OUT_ROOT):
+        return []
+    return [n for n in sorted(os.listdir(OUT_ROOT))
+            if os.path.isfile(os.path.join(OUT_ROOT, n, "library.json"))]
+
+
+def safe_join(root, *parts):
+    p = posixpath.normpath("/".join(parts)).lstrip("/")
+    full = os.path.join(root, *p.split("/"))
+    if not os.path.abspath(full).startswith(os.path.abspath(root)):
+        raise ValueError("path traversal")
+    return full
+
+
+def _job_snapshot(job_id):
+    with JOBS_LOCK:
+        job = JOBS.get(job_id)
+        return dict(job) if job else None
+
+
+def _set_job(job_id, **fields):
+    with JOBS_LOCK:
+        job = JOBS.setdefault(job_id, {})
+        job.update(fields)
+
+
+def _append_job_log(job_id, msg):
+    with JOBS_LOCK:
+        job = JOBS.setdefault(job_id, {})
+        job.setdefault("logs", []).append(msg)
+        job["updatedAt"] = time.time()
+
+
+def _run_generate_job(job_id, manifest, creds):
+    _set_job(job_id, status="running", logs=[], game=manifest.get("game"), startedAt=time.time(), updatedAt=time.time())
+    try:
+        lib, _ = pipeline.run(manifest, OUT_ROOT, creds=creds, log=lambda m: _append_job_log(job_id, m))
+        _set_job(job_id, status="done", ok=True, game=lib["game"], updatedAt=time.time())
+    except Exception as e:
+        traceback.print_exc()
+        _append_job_log(job_id, f"❌ 生成任务失败: {e}")
+        _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
+
+
+def _split_lines(value):
+    if isinstance(value, list):
+        return [str(x).strip() for x in value if str(x).strip()]
+    return [x.strip() for x in str(value or "").splitlines() if x.strip()]
+
+
+def _creative_fallback(data):
+    brief = (data.get("brief") or "").lower()
+    text = " ".join([brief, data.get("styleNotes", "").lower(), data.get("avoidNotes", "").lower()])
+    theme = "jelly"
+    if any(k in text for k in ("egypt", "埃及", "金字塔", "法老")):
+        theme = "egypt"
+    elif any(k in text for k in ("pirate", "海盗", "treasure", "宝藏")):
+        theme = "pirate"
+    elif any(k in text for k in ("cyber", "赛博", "neon", "霓虹")):
+        theme = "cyber"
+    elif any(k in text for k in ("fruit", "水果", "cherry", "樱桃")):
+        theme = "fruit"
+    reel_mode = "cluster" if any(k in text for k in ("消除", "cluster", "连通", "match")) else "ways"
+    volatility = "high" if any(k in text for k in ("刺激", "大奖", "高波动", "big win")) else "medium"
+    features = ["cascades", "free_spins", "wilds"]
+    if any(k in text for k in ("金币", "jackpot", "hold", "respin", "大奖池")):
+        features.append("hold_win")
+    if any(k in text for k in ("倍率", "multiplier", "连锁")):
+        features.append("multipliers")
+    title = data.get("title") or "AI Custom Slot"
+    style = data.get("styleNotes") or data.get("brief") or ""
+    return {
+        "gameId": data.get("gameId") or title,
+        "title": title,
+        "theme": theme,
+        "style": style,
+        "reelMode": reel_mode,
+        "volatility": volatility,
+        "targetRtp": data.get("targetRtp", 96),
+        "characterCount": data.get("characterCount", 10),
+        "uiCompleteness": data.get("uiCompleteness", "full"),
+        "feedbackIntensity": data.get("feedbackIntensity", "standard"),
+        "enableMathModel": bool(data.get("enableMathModel", True)),
+        "features": features,
+    }
+
+
+def build_game_plan(slot_request, creative_payload, source):
+    features = slot_request.get("features") or []
+    hook_parts = []
+    if "hold_win" in features:
+        hook_parts.append("金币锁格 respin 大奖目标")
+    if "cascades" in features:
+        hook_parts.append("连锁下落爽感")
+    if "free_spins" in features:
+        hook_parts.append("Scatter 免费旋转期待")
+    if "multipliers" in features:
+        hook_parts.append("递增倍率爆点")
+    core_hook = " + ".join(hook_parts[:2]) or "轻量现代老虎机循环"
+    vision = creative_payload.get("visionStyleAnalysis") or {}
+    art_direction = {
+        "theme": slot_request.get("theme"),
+        "style": slot_request.get("style", ""),
+        "reference_style_analysis": vision,
+        "avoid": creative_payload.get("avoidNotes", ""),
+    }
+    return {
+        "source": source,
+        "creative": {
+            "brief": creative_payload.get("brief", ""),
+            "references": creative_payload.get("references", []),
+            "uploadedReferenceImages": creative_payload.get("uploadedReferenceImages", 0),
+            "visionStyleAnalysis": vision,
+            "visionStyleAnalysisError": creative_payload.get("visionStyleAnalysisError", ""),
+        },
+        "gameDesign": {
+            "title": slot_request.get("title"),
+            "coreHook": core_hook,
+            "artDirection": art_direction,
+            "reelExperience": slot_request.get("reelMode"),
+            "volatility": slot_request.get("volatility"),
+            "differentiators": hook_parts,
+            "assetStrategy": {
+                "symbolCount": slot_request.get("characterCount"),
+                "uiCompleteness": slot_request.get("uiCompleteness"),
+                "feedbackIntensity": slot_request.get("feedbackIntensity"),
+            },
+        },
+    }
+
+
+def creative_to_slot_request(data):
+    api_key = (data.get("api_key") or DEFAULT_API_KEY).strip()
+    base_url = (data.get("base_url") or DEFAULT_BASE_URL).strip()
+    text_model = (data.get("text_model") or DEFAULT_TEXT_MODEL).strip()
+    references = _split_lines(data.get("references"))
+    reference_images = data.get("reference_images") or []
+    if not isinstance(reference_images, list):
+        reference_images = []
+    payload = {
+        "title": data.get("title") or "AI Custom Slot",
+        "gameId": data.get("gameId") or "",
+        "brief": data.get("brief") or "",
+        "references": references,
+        "uploadedReferenceImages": len(reference_images),
+        "styleNotes": data.get("styleNotes") or "",
+        "avoidNotes": data.get("avoidNotes") or "",
+        "targetRtp": data.get("targetRtp", 96),
+        "enableMathModel": bool(data.get("enableMathModel", True)),
+    }
+    if not api_key:
+        fallback = _creative_fallback(payload)
+        return fallback, "fallback_no_api_key", build_game_plan(fallback, payload, "fallback_no_api_key")
+    vision_notes = {}
+    direct_image_urls = [
+        x for x in references
+        if x.lower().startswith(("http://", "https://")) and x.lower().split("?")[0].endswith((".png", ".jpg", ".jpeg", ".webp"))
+    ]
+    if direct_image_urls or reference_images:
+        try:
+            vision_notes = providers.analyze_reference_images(
+                reference_urls=direct_image_urls,
+                image_data_urls=reference_images,
+                api_key=api_key,
+                base_url=base_url,
+                model=text_model,
+            )
+            payload["visionStyleAnalysis"] = vision_notes
+            if vision_notes.get("image_prompt_style"):
+                payload["styleNotes"] = ";".join([
+                    str(payload.get("styleNotes") or ""),
+                    "视觉参考分析:" + str(vision_notes.get("image_prompt_style")),
+                ]).strip(";")
+            elif vision_notes:
+                payload["styleNotes"] = ";".join([
+                    str(payload.get("styleNotes") or ""),
+                    "视觉参考分析:" + json.dumps(vision_notes, ensure_ascii=False),
+                ]).strip(";")
+        except Exception as e:
+            payload["visionStyleAnalysisError"] = str(e)[:500]
+    messages = [
+        {
+            "role": "system",
+            "content": (
+                "你是资深移动老虎机游戏策划和美术总监。根据用户的基础描述、参考图或网址、风格要求,"
+                "生成一份可执行的 slot 工作流输入 JSON。必须原创,不能复刻参考图里的具体 IP、logo、角色。"
+                "只输出 JSON,不要解释。"
+            ),
+        },
+        {
+            "role": "user",
+            "content": json.dumps({
+                "task": "把创意简报转换为 slot_workflow.build_workflow 可接受的输入",
+                "allowedFields": {
+                    "gameId": "string slug or title",
+                    "title": "string",
+                    "theme": "jelly|fruit|egypt|pirate|cyber",
+                    "style": "English image-generation style prompt, include reference-derived art direction",
+                    "reelMode": "ways|paylines|megaways|cluster",
+                    "volatility": "low|medium|high",
+                    "targetRtp": "number percent, e.g. 96",
+                    "characterCount": "6|8|10|12",
+                    "uiCompleteness": "basic|full",
+                    "feedbackIntensity": "quiet|standard|loud",
+                    "enableMathModel": "boolean",
+                    "features": "array of cascades, free_spins, wilds, hold_win, multipliers"
+                },
+                "creativeBrief": payload,
+                "outputRules": [
+                    "Return JSON object with exactly these workflow fields.",
+                    "Pick one clear core hook, not every feature.",
+                    "Use references as style inspiration only; do not copy protected characters, logos, or exact layout.",
+                    "Style must be specific enough for image generation."
+                ],
+            }, ensure_ascii=False),
+        },
+    ]
+    try:
+        obj = providers.chat_json_openai(messages, api_key=api_key, base_url=base_url, model=text_model)
+    except Exception:
+        fallback = _creative_fallback(payload)
+        return fallback, "fallback_text_model_failed", build_game_plan(fallback, payload, "fallback_text_model_failed")
+    fallback = _creative_fallback(payload)
+    out = dict(fallback)
+    for key in ("gameId", "title", "theme", "style", "reelMode", "volatility", "targetRtp",
+                "characterCount", "uiCompleteness", "feedbackIntensity", "enableMathModel"):
+        if key in obj and obj[key] not in (None, ""):
+            out[key] = obj[key]
+    if isinstance(obj.get("features"), list) and obj["features"]:
+        allowed = {"cascades", "free_spins", "wilds", "hold_win", "multipliers"}
+        out["features"] = [x for x in obj["features"] if x in allowed]
+    source = "vision_text_model" if vision_notes else "text_model"
+    return out, source, build_game_plan(out, payload, source)
+
+
+class Handler(BaseHTTPRequestHandler):
+    def _send(self, code, body, ctype="application/json; charset=utf-8"):
+        if isinstance(body, (dict, list)):
+            body = json.dumps(body, ensure_ascii=False).encode("utf-8")
+        elif isinstance(body, str):
+            body = body.encode("utf-8")
+        self.send_response(code)
+        self.send_header("Content-Type", ctype)
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+    def _file(self, path):
+        if not os.path.isfile(path):
+            return self._send(404, {"error": "not found"})
+        ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
+        with open(path, "rb") as f:
+            data = f.read()
+        self.send_response(200)
+        self.send_header("Content-Type", ctype)
+        self.send_header("Content-Length", str(len(data)))
+        self.end_headers()
+        self.wfile.write(data)
+
+    def log_message(self, *a):
+        pass  # 静音
+
+    def do_GET(self):
+        u = urlparse(self.path)
+        path, qs = u.path, parse_qs(u.query)
+        if path == "/" or path == "/index.html":
+            return self._file(os.path.join(WEB_DIR, "index.html"))
+        if path == "/api/manifest":
+            with open(DEFAULT_MANIFEST, encoding="utf-8") as f:
+                return self._send(200, f.read())
+        if path == "/api/games":
+            return self._send(200, {"games": list_games()})
+        if path == "/api/job":
+            job_id = qs.get("id", [""])[0]
+            job = _job_snapshot(job_id)
+            if not job:
+                return self._send(404, {"ok": False, "error": "job not found"})
+            job["id"] = job_id
+            return self._send(200, job)
+        if path == "/api/library":
+            games = list_games()
+            game = (qs.get("game", [None])[0]) or (games[-1] if games else None)
+            if not game:
+                return self._send(200, {"game": None, "games": games,
+                                        "characters": [], "vfx": [], "ui": []})
+            library_path = os.path.join(OUT_ROOT, game, "library.json")
+            if not os.path.isfile(library_path):
+                return self._send(200, {"game": game, "games": games, "assetBase": f"/assets/{game}/",
+                                        "characters": [], "vfx": [], "ui": []})
+            with open(library_path, encoding="utf-8") as f:
+                lib = json.load(f)
+            lib["games"] = games
+            lib["assetBase"] = f"/assets/{game}/"
+            return self._send(200, lib)
+        if path.startswith("/assets/"):
+            rel = unquote(path[len("/assets/"):])
+            try:
+                return self._file(safe_join(OUT_ROOT, rel))
+            except ValueError:
+                return self._send(400, {"error": "bad path"})
+        return self._send(404, {"error": "not found"})
+
+    def do_POST(self):
+        try:
+            return self._do_POST()
+        except Exception as e:
+            traceback.print_exc()
+            return self._send(500, {"ok": False, "error": str(e)})
+
+    def _read_json_body(self):
+        length = int(self.headers.get("Content-Length", 0))
+        return json.loads(self.rfile.read(length) or b"{}")
+
+    def _do_POST(self):
+        route = urlparse(self.path).path
+        if route == "/api/export":
+            return self._post_export()
+        if route == "/api/delete":
+            return self._post_delete()
+        if route == "/api/slot-workflow":
+            return self._post_slot_workflow()
+        if route == "/api/creative-manifest":
+            return self._post_creative_manifest()
+        if route != "/api/generate":
+            return self._send(404, {"error": "not found"})
+        try:
+            data = self._read_json_body()
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
+        try:
+            manifest = json.loads(data["manifest"]) if isinstance(data.get("manifest"), str) \
+                else data.get("manifest")
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"manifest 非法 JSON: {e}"})
+        creds = {
+            "provider": data.get("provider", "OpenAI 兼容接口"),
+            "api_key": (data.get("api_key") or DEFAULT_API_KEY).strip(),
+            "base_url": (data.get("base_url") or DEFAULT_BASE_URL).strip(),
+            "model": (data.get("model") or DEFAULT_IMAGE_MODEL).strip(),
+            "size": data.get("size", "1024x1024"),
+            "remove_bg": bool(data.get("remove_bg", False)),
+        }
+        logs = []
+        if data.get("async", True):
+            job_id = uuid.uuid4().hex
+            with JOBS_LOCK:
+                JOBS[job_id] = {"status": "queued", "ok": None, "logs": ["任务已创建,等待开始…"],
+                                "game": manifest.get("game"), "createdAt": time.time(), "updatedAt": time.time()}
+            threading.Thread(target=_run_generate_job, args=(job_id, manifest, creds), daemon=True).start()
+            return self._send(200, {"ok": True, "jobId": job_id, "game": manifest.get("game")})
+        try:
+            lib, _ = pipeline.run(manifest, OUT_ROOT, creds=creds, log=lambda m: logs.append(m))
+        except Exception as e:
+            return self._send(500, {"ok": False, "error": str(e), "logs": logs})
+        return self._send(200, {"ok": True, "logs": logs, "game": lib["game"]})
+
+    def _post_slot_workflow(self):
+        try:
+            data = self._read_json_body()
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
+        try:
+            result = slot_workflow.build_workflow(data)
+        except Exception as e:
+            traceback.print_exc()
+            return self._send(500, {"ok": False, "error": str(e)})
+        return self._send(200, {"ok": True, **result})
+
+    def _post_creative_manifest(self):
+        try:
+            data = self._read_json_body()
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
+        try:
+            normalized, source, game_plan = creative_to_slot_request(data)
+            normalized["creative"] = game_plan.get("creative", {})
+            normalized["gameDesign"] = game_plan.get("gameDesign", {})
+            result = slot_workflow.build_workflow(normalized)
+            return self._send(200, {"ok": True, "source": source, "game_plan": game_plan,
+                                    "creative_request": normalized, **result})
+        except Exception as e:
+            traceback.print_exc()
+            return self._send(500, {"ok": False, "error": str(e)})
+
+    def _post_export(self):
+        try:
+            data = self._read_json_body()
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
+        game = (data.get("game") or "").strip()
+        if not game or game not in list_games():
+            return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
+        logs = []
+        try:
+            pack = exporter.export(game, OUT_ROOT, log=lambda m: logs.append(m))
+        except Exception as e:
+            traceback.print_exc()
+            return self._send(500, {"ok": False, "error": str(e), "logs": logs})
+        return self._send(200, {"ok": True, "logs": logs, "game": game,
+                                "pack": os.path.abspath(pack)})
+
+    def _post_delete(self):
+        try:
+            data = self._read_json_body()
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
+        game = (data.get("game") or "").strip()
+        if not game or game not in list_games():
+            return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
+        target = os.path.join(OUT_ROOT, game)
+        # 安全校验:必须落在 OUT_ROOT 内
+        if not os.path.abspath(target).startswith(os.path.abspath(OUT_ROOT) + os.sep):
+            return self._send(400, {"ok": False, "error": "非法路径"})
+        shutil.rmtree(target)
+        return self._send(200, {"ok": True, "deleted": game, "games": list_games()})
+
+
+if __name__ == "__main__":
+    os.makedirs(OUT_ROOT, exist_ok=True)
+    print(f"Anim Studio 网站: http://127.0.0.1:{PORT}")
+    ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()

+ 205 - 0
slot_game_config_template.json

@@ -0,0 +1,205 @@
+{
+  "schemaVersion": "slot_game_config.v1",
+  "game": {
+    "id": "jelly-candy-slot",
+    "title": "Jelly Candy Slot",
+    "mode": "demo_only",
+    "engine": "cocos_creator_3_8",
+    "orientation": "portrait",
+    "targetViewport": { "width": 798, "height": 1724 },
+    "audienceFeel": ["casual", "juicy", "bright", "fast"]
+  },
+  "theme": {
+    "world": "jelly candy land",
+    "visualStyle": "cute 3D-rendered translucent jelly creatures, glossy gummy texture, soft highlights, mobile game asset style",
+    "palette": ["pink", "blue", "purple", "gold", "lemon yellow"],
+    "avoid": ["photorealistic human", "dark horror", "busy text", "copied logos", "checkerboard background"]
+  },
+  "reels": {
+    "mode": "ways",
+    "columns": 5,
+    "rows": 3,
+    "payDirection": "left_to_right",
+    "paylines": {
+      "enabled": false,
+      "count": 25,
+      "patterns": []
+    },
+    "ways": {
+      "enabled": true,
+      "waysCount": 243,
+      "minAdjacentReels": 3
+    },
+    "megaways": {
+      "enabled": false,
+      "columns": 6,
+      "rowRange": [2, 7],
+      "maxWays": 117649
+    },
+    "clusterPays": {
+      "enabled": false,
+      "grid": { "columns": 6, "rows": 5 },
+      "minClusterSize": 5,
+      "adjacency": "orthogonal"
+    }
+  },
+  "mathProfile": {
+    "volatility": "medium",
+    "hitFrequencyFeel": "medium",
+    "maxWinMultiplier": 5000,
+    "rtpTargetLabel": "demo_not_certified",
+    "note": "Prototype config only. Real-money RTP, symbol weights, certification, and jurisdiction compliance are outside this template."
+  },
+  "symbols": {
+    "low": [
+      { "id": "jelly_blue", "role": "low_symbol", "countWeight": 18 },
+      { "id": "jelly_green", "role": "low_symbol", "countWeight": 18 },
+      { "id": "jelly_orange", "role": "low_symbol", "countWeight": 16 }
+    ],
+    "high": [
+      { "id": "jelly_purple", "role": "high_symbol", "countWeight": 10 },
+      { "id": "jelly_rainbow", "role": "premium_symbol", "countWeight": 6 },
+      { "id": "symbol_seven", "role": "top_symbol", "countWeight": 4 }
+    ],
+    "special": [
+      {
+        "id": "wild",
+        "type": "wild",
+        "substitutesFor": ["low", "high"],
+        "canAppearInBaseGame": true,
+        "canAppearInFreeSpins": true
+      },
+      {
+        "id": "scatter",
+        "type": "scatter",
+        "trigger": "free_spins",
+        "requiredCount": 3,
+        "canAppearAnywhere": true
+      },
+      {
+        "id": "coin_cash",
+        "type": "cash",
+        "trigger": "hold_and_win",
+        "valueRangeMultiplier": [1, 100]
+      },
+      {
+        "id": "collect",
+        "type": "collect",
+        "collects": "coin_cash"
+      }
+    ]
+  },
+  "paytable": {
+    "currency": "credits",
+    "lineBetMultiplierPayouts": {
+      "low": { "3": 0.2, "4": 0.6, "5": 1.2 },
+      "high": { "3": 0.5, "4": 1.5, "5": 4 },
+      "premium_symbol": { "3": 1, "4": 4, "5": 12 },
+      "top_symbol": { "3": 2, "4": 10, "5": 50 }
+    },
+    "scatterPays": { "3": 2, "4": 10, "5": 50 }
+  },
+  "features": {
+    "wilds": {
+      "enabled": true,
+      "variant": "expanding",
+      "expandsOnReels": [2, 3, 4],
+      "stickyDuringFreeSpins": false,
+      "multiplierRange": [1, 1]
+    },
+    "scatterFreeSpins": {
+      "enabled": true,
+      "triggerCount": 3,
+      "spinAwards": { "3": 8, "4": 12, "5": 20 },
+      "retrigger": true,
+      "freeSpinModifiers": ["more_wilds", "win_multiplier"]
+    },
+    "cascades": {
+      "enabled": true,
+      "removeWinningSymbols": true,
+      "dropDirection": "down",
+      "baseMultiplierStart": 1,
+      "baseMultiplierStep": 1,
+      "freeSpinMultiplierStep": 2,
+      "maxCascadeCount": 8
+    },
+    "holdAndWin": {
+      "enabled": false,
+      "triggerCashSymbolCount": 6,
+      "initialRespins": 3,
+      "resetRespinsOnNewCashSymbol": true,
+      "lockCashSymbols": true,
+      "jackpots": [
+        { "id": "mini", "valueMultiplier": 20 },
+        { "id": "minor", "valueMultiplier": 50 },
+        { "id": "major", "valueMultiplier": 200 },
+        { "id": "grand", "valueMultiplier": 1000 }
+      ]
+    },
+    "pickBonus": {
+      "enabled": false,
+      "triggerSymbol": "bonus",
+      "pickCount": 3,
+      "rewardPool": ["credits", "multiplier", "free_spins"]
+    }
+  },
+  "playerControls": {
+    "spin": true,
+    "turbo": true,
+    "autoSpin": true,
+    "betLevels": [1, 2, 5, 10, 20, 50, 100],
+    "defaultBet": 10,
+    "showPaytable": true,
+    "showRules": true
+  },
+  "feedback": {
+    "overallFeel": "elastic, bright, satisfying, not too noisy",
+    "intensity": {
+      "tap": "micro",
+      "smallWin": "normal_win",
+      "bigWin": "big_win",
+      "bonus": "bonus"
+    },
+    "events": [
+      {
+        "id": "tap_spin",
+        "trigger": "player_taps_spin_button",
+        "uiAnimation": ["spin_btn_press"],
+        "vfx": [],
+        "durationMs": 180
+      },
+      {
+        "id": "small_win",
+        "trigger": "payout_greater_than_bet",
+        "characterAnimation": "win",
+        "uiAnimation": ["scale_bounce", "number_roll"],
+        "vfx": ["win_burst"],
+        "durationMs": 900
+      },
+      {
+        "id": "big_win",
+        "trigger": "payout_at_least_10x_bet",
+        "characterAnimation": "win",
+        "uiAnimation": ["reward_popup_in", "number_roll", "win_icon_pulse"],
+        "vfx": ["coin_rain", "bigwin_glow", "confetti_pop"],
+        "durationMs": 2400,
+        "blocksInput": true
+      },
+      {
+        "id": "free_spins_intro",
+        "trigger": "scatter_free_spins_triggered",
+        "uiAnimation": ["elastic_in", "pulse"],
+        "vfx": ["confetti_pop", "bigwin_glow"],
+        "durationMs": 1800,
+        "blocksInput": true
+      }
+    ]
+  },
+  "assetGeneration": {
+    "characterCount": 10,
+    "uiArt": ["bg_main", "logo", "reel_frame", "btn_spin", "btn_round", "hud_pill", "win_popup", "free_spin_badge"],
+    "vfx": ["coin_rain", "win_burst", "bigwin_glow", "confetti_pop"],
+    "uiTweenPresets": ["scale_bounce", "elastic_in", "fade_slide_in", "number_roll", "pulse"],
+    "manifestOutput": "animation_manifest.json"
+  }
+}

+ 270 - 0
slot_math.py

@@ -0,0 +1,270 @@
+"""Deterministic math-package builder for slot prototypes.
+
+This is not a legal certification by itself. It creates the reproducible math
+artifacts a lab normally needs: RNG spec, reel strips, pay scaling, simulation
+summary, and a stable hash of the math model.
+"""
+
+import copy
+import hashlib
+import json
+import random
+
+
+TARGET_RTP_BY_VOL = {"low": 0.94, "medium": 0.96, "high": 0.965}
+SAMPLES_BY_VOL = {"low": 2500, "medium": 4000, "high": 6000}
+
+
+def _stable_hash(data):
+    payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
+    return hashlib.sha256(payload).hexdigest()
+
+
+def _target_rtp(slot_config):
+    value = slot_config.get("mathProfile", {}).get("rtpTarget")
+    if value is None:
+        value = TARGET_RTP_BY_VOL.get(slot_config.get("mathProfile", {}).get("volatility"), 0.96)
+    try:
+        value = float(value)
+    except (TypeError, ValueError):
+        value = 0.96
+    if value > 1:
+        value = value / 100.0
+    return max(0.5, min(0.995, value))
+
+
+def _role_map(slot_config):
+    return {s["id"]: s.get("role", "regular") for s in slot_config.get("symbols", [])}
+
+
+def _is(role_map, sid, role):
+    if role == "bonus":
+        return role_map.get(sid) == "bonus" or "coin" in sid or sid == "collect"
+    if role == "scatter":
+        return role_map.get(sid) == "scatter" or "scatter" in sid
+    if role == "wild":
+        return role_map.get(sid) == "wild" or sid == "wild"
+    return role_map.get(sid) == role
+
+
+def _build_reel_strips(slot_config, seed):
+    cols = int(slot_config["reels"]["columns"])
+    strip_len = 64 if cols <= 5 else 72
+    rng = random.Random(seed)
+    symbols = slot_config.get("symbols", [])
+    weighted = []
+    for item in symbols:
+        weight = max(1, int(item.get("weight", 1)))
+        weighted.extend([item["id"]] * weight)
+    if not weighted:
+        weighted = ["symbol"]
+    strips = []
+    for c in range(cols):
+        reel = [weighted[(i * 7 + c * 11) % len(weighted)] for i in range(strip_len)]
+        rng.shuffle(reel)
+        strips.append(reel)
+    return strips
+
+
+def _draw_grid(strips, rows, rng):
+    grid = []
+    for reel in strips:
+        start = rng.randrange(len(reel))
+        grid.append([reel[(start + r) % len(reel)] for r in range(rows)])
+    return grid
+
+
+def _count(grid):
+    out = {}
+    for col in grid:
+        for sid in col:
+            out[sid] = out.get(sid, 0) + 1
+    return out
+
+
+def _find_wins(slot_config, grid):
+    role_map = _role_map(slot_config)
+    counts = _count(grid)
+    wilds = sum(v for k, v in counts.items() if _is(role_map, k, "wild"))
+    min_match = int(slot_config.get("winRules", {}).get("minMatch", 4))
+    if slot_config.get("winRules", {}).get("evaluation") == "cluster_count":
+        cols, rows = len(grid), len(grid[0]) if grid else 0
+        groups = []
+        regulars = [s["id"] for s in slot_config.get("symbols", []) if s.get("role") == "regular"]
+        for sid in regulars:
+            seen = set()
+            for c in range(cols):
+                for r in range(rows):
+                    if (c, r) in seen:
+                        continue
+                    if grid[c][r] != sid and not _is(role_map, grid[c][r], "wild"):
+                        continue
+                    stack = [(c, r)]
+                    seen.add((c, r))
+                    n = 0
+                    while stack:
+                        x, y = stack.pop()
+                        n += 1
+                        for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
+                            if nx < 0 or nx >= cols or ny < 0 or ny >= rows or (nx, ny) in seen:
+                                continue
+                            if grid[nx][ny] == sid or _is(role_map, grid[nx][ny], "wild"):
+                                seen.add((nx, ny))
+                                stack.append((nx, ny))
+                    if n >= min_match:
+                        groups.append((sid, min(6, n)))
+        return groups
+    wins = []
+    for sid, n in counts.items():
+        if _is(role_map, sid, "wild") or _is(role_map, sid, "scatter") or _is(role_map, sid, "bonus"):
+            continue
+        if n + wilds >= min_match:
+            wins.append((sid, min(6, max(min_match, n + wilds))))
+    return wins
+
+
+def _table_pay(slot_config, wins):
+    paytable = slot_config.get("paytable", {})
+    total = 0.0
+    for sid, n in wins:
+        table = paytable.get(sid, {})
+        total += float(table.get(str(n), table.get(str(slot_config.get("winRules", {}).get("minMatch", 4)), n)) or 0)
+    return total
+
+
+def _feature_pay(slot_config, sid, count):
+    table = slot_config.get("paytable", {}).get(sid, {})
+    n = min(6, max(3, count))
+    return float(table.get(str(n), 0) or 0)
+
+
+def _hold_raw_pay(slot_config, grid, rng):
+    features = slot_config.get("features", {})
+    rules = features.get("holdAndWin", {})
+    if not rules.get("enabled"):
+        return 0.0
+    role_map = _role_map(slot_config)
+    cols, rows = len(grid), len(grid[0]) if grid else 0
+    held = [[_is(role_map, grid[c][r], "bonus") for r in range(rows)] for c in range(cols)]
+    if sum(1 for c in range(cols) for r in range(rows) if held[c][r]) < int(rules.get("triggerCount", 6)):
+        return 0.0
+    respins = int(rules.get("respins", 3))
+    total = sum(1 + ((c + r) % 5) for c in range(cols) for r in range(rows) if held[c][r])
+    while respins > 0:
+        new_coin = False
+        for c in range(cols):
+            for r in range(rows):
+                if held[c][r]:
+                    continue
+                if rng.random() < 0.18:
+                    held[c][r] = True
+                    total += 1 + ((c + r) % 5)
+                    new_coin = True
+        respins = int(rules.get("respins", 3)) if new_coin else respins - 1
+        if all(held[c][r] for c in range(cols) for r in range(rows)):
+            break
+    return float(total)
+
+
+def _simulate_raw(slot_config, strips, seed, base_spins):
+    rng = random.Random(seed)
+    rows = int(slot_config["reels"]["rows"])
+    scatter_rules = slot_config.get("features", {}).get("scatterFreeSpins", {})
+    scatter_trigger = int(scatter_rules.get("triggerCount", 3))
+    scatter_award = int(scatter_rules.get("awardSpins", 8))
+    role_map = _role_map(slot_config)
+    total_pay = 0.0
+    hit_spins = 0
+    free_spins_awarded = 0
+    free_spins_played = 0
+    hold_triggers = 0
+    spin_pays = []
+
+    for _ in range(base_spins):
+        queue = [False]
+        free_guard = 0
+        while queue:
+            is_free = queue.pop(0)
+            if is_free:
+                free_spins_played += 1
+            grid = _draw_grid(strips, rows, rng)
+            counts = _count(grid)
+            pay = _table_pay(slot_config, _find_wins(slot_config, grid))
+            if scatter_rules.get("enabled"):
+                scatter_count = sum(v for k, v in counts.items() if _is(role_map, k, "scatter"))
+                if scatter_count >= scatter_trigger:
+                    pay += _feature_pay(slot_config, "scatter", scatter_count)
+                    award = min(scatter_award, 120 - free_guard)
+                    if award > 0:
+                        free_spins_awarded += award
+                        free_guard += award
+                        queue.extend([True] * award)
+            hold_pay = _hold_raw_pay(slot_config, grid, rng)
+            if hold_pay > 0:
+                hold_triggers += 1
+                pay += hold_pay
+            total_pay += pay
+            if pay > 0:
+                hit_spins += 1
+            spin_pays.append(pay)
+    mean = total_pay / max(1, base_spins)
+    variance = sum((p - mean) ** 2 for p in spin_pays) / max(1, len(spin_pays))
+    return {
+        "rawRtp": mean,
+        "hitFrequency": hit_spins / max(1, len(spin_pays)),
+        "stdDevPerSpin": variance ** 0.5,
+        "baseSpins": base_spins,
+        "totalResolvedSpins": len(spin_pays),
+        "freeSpinsAwarded": free_spins_awarded,
+        "freeSpinsPlayed": free_spins_played,
+        "holdAndWinTriggers": hold_triggers,
+    }
+
+
+def build_math_model(slot_config):
+    cfg = copy.deepcopy(slot_config)
+    target_rtp = _target_rtp(cfg)
+    seed_source = {
+        "game": cfg.get("game", {}).get("id"),
+        "reels": cfg.get("reels"),
+        "symbols": cfg.get("symbols"),
+        "features": cfg.get("features"),
+        "targetRtp": target_rtp,
+    }
+    seed = int(_stable_hash(seed_source)[:12], 16)
+    strips = _build_reel_strips(cfg, seed)
+    samples = SAMPLES_BY_VOL.get(cfg.get("mathProfile", {}).get("volatility"), 30000)
+    raw = _simulate_raw(cfg, strips, seed + 1, samples)
+    raw_rtp = max(0.0001, raw["rawRtp"])
+    payout_scale = target_rtp / raw_rtp
+    report = {
+        "targetRtp": round(target_rtp, 6),
+        "rawRtpBeforeScale": round(raw["rawRtp"], 6),
+        "estimatedRtp": round(raw["rawRtp"] * payout_scale, 6),
+        "payoutScale": round(payout_scale, 8),
+        "hitFrequency": round(raw["hitFrequency"], 6),
+        "stdDevPerSpinBeforeScale": round(raw["stdDevPerSpin"], 6),
+        "stdDevPerSpin": round(raw["stdDevPerSpin"] * payout_scale, 6),
+        "baseSpins": raw["baseSpins"],
+        "totalResolvedSpins": raw["totalResolvedSpins"],
+        "freeSpinsAwarded": raw["freeSpinsAwarded"],
+        "freeSpinsPlayed": raw["freeSpinsPlayed"],
+        "holdAndWinTriggers": raw["holdAndWinTriggers"],
+    }
+    math_model = {
+        "status": "certification_candidate_not_lab_certified",
+        "rng": {
+            "algorithm": "deterministic_seeded_mt19937_for_simulation",
+            "productionRequirement": "replace_with_regulator_approved_csprng_or_platform_rng",
+            "seed": seed,
+        },
+        "reelStrips": strips,
+        "simulation": report,
+        "payoutScale": report["payoutScale"],
+        "notes": [
+            "Generated by deterministic workflow for review and lab handoff.",
+            "This package is not a legal certification result without third-party lab approval.",
+        ],
+    }
+    math_model["modelHash"] = _stable_hash(math_model)
+    return math_model

+ 367 - 0
slot_workflow.py

@@ -0,0 +1,367 @@
+"""Slot game workflow helpers.
+
+This module turns a small set of form choices into the manifest format that the
+existing generation pipeline already understands. It is deterministic on
+purpose: the text-model step can be added later, but the UI should already have a
+stable "define game -> manifest -> generate" path.
+"""
+
+import copy
+import re
+
+import slot_math
+
+
+DEFAULT_STYLE_BY_THEME = {
+    "jelly": "cute 3D-rendered translucent jelly creatures, glossy gummy texture, soft subsurface highlights, warm candy-land color palette, mobile slot game asset, centered, clean edges",
+    "fruit": "bright juicy fruit slot game, glossy 3D fruit icons, fresh arcade colors, mobile game asset, centered, clean edges",
+    "egypt": "golden ancient egypt treasure slot game, polished gems, warm sandstone and turquoise palette, premium mobile game asset, centered, clean edges",
+    "pirate": "playful pirate treasure slot game, glossy gold coins, tropical ocean colors, stylized 3D mobile game asset, centered, clean edges",
+    "cyber": "neon cyber arcade slot game, glowing glass panels, electric blue and magenta palette, premium mobile game asset, centered, clean edges",
+}
+
+
+THEME_LABELS = {
+    "jelly": "果冻糖果",
+    "fruit": "水果乐园",
+    "egypt": "埃及宝藏",
+    "pirate": "海盗金币",
+    "cyber": "霓虹赛博",
+}
+
+VOLATILITY_RULES = {
+    "low": {
+        "hitFrequencyFeel": "high",
+        "minMatch": 3,
+        "payMultiplier": 0.82,
+        "bonusChanceLabel": "frequent_small",
+        "cascadeMultiplierStep": 1,
+        "maxCascadeMultiplier": 4,
+    },
+    "medium": {
+        "hitFrequencyFeel": "medium",
+        "minMatch": 4,
+        "payMultiplier": 1.0,
+        "bonusChanceLabel": "balanced",
+        "cascadeMultiplierStep": 1,
+        "maxCascadeMultiplier": 6,
+    },
+    "high": {
+        "hitFrequencyFeel": "low",
+        "minMatch": 5,
+        "payMultiplier": 1.35,
+        "bonusChanceLabel": "rare_large",
+        "cascadeMultiplierStep": 2,
+        "maxCascadeMultiplier": 10,
+    },
+}
+
+FEEDBACK_RULES = {
+    "quiet": {
+        "spinDurationSec": 0.32,
+        "dropTimeSec": 0.24,
+        "clearPopScale": 1.18,
+        "screenShake": False,
+        "bigWinThresholdBet": 18,
+        "vfxDensity": "light",
+    },
+    "standard": {
+        "spinDurationSec": 0.45,
+        "dropTimeSec": 0.30,
+        "clearPopScale": 1.30,
+        "screenShake": False,
+        "bigWinThresholdBet": 25,
+        "vfxDensity": "medium",
+    },
+    "loud": {
+        "spinDurationSec": 0.58,
+        "dropTimeSec": 0.36,
+        "clearPopScale": 1.42,
+        "screenShake": True,
+        "bigWinThresholdBet": 35,
+        "vfxDensity": "heavy",
+    },
+}
+
+
+BASE_SYMBOLS = {
+    "jelly": [
+        ("jelly_blue", "a round blue blueberry jelly creature mascot with big friendly eyes and a happy open smile, tiny arms raised"),
+        ("jelly_pink", "a pink strawberry jelly creature with sparkling eyes and a small leaf on top, cheerful pose"),
+        ("jelly_green", "a green apple jelly creature with rosy cheeks and a cute grin"),
+        ("jelly_orange", "an orange citrus jelly creature with bright eyes and little stubby hands"),
+        ("jelly_purple", "a purple grape jelly creature, slightly wobbly, shy smile, sparkles"),
+        ("jelly_lemon", "a yellow lemon jelly creature with a wide excited smile and star-shaped eyes"),
+        ("jelly_choco", "a glossy chocolate jelly creature with caramel swirls and warm happy eyes"),
+        ("jelly_rainbow", "a rainbow gradient jelly creature, extra glossy and translucent, joyful expression, premium special character"),
+        ("symbol_coin", "a shiny golden coin symbol with a smiling jelly face embossed, glossy game icon"),
+        ("symbol_seven", "a glossy candy-styled lucky number seven symbol, red and gold, jelly texture, game slot icon"),
+    ],
+    "fruit": [
+        ("symbol_cherry", "a glossy cherry pair slot symbol with cute eyes, premium 3D mobile game icon"),
+        ("symbol_lemon", "a bright lemon slice slot symbol, glossy and juicy, cute arcade style"),
+        ("symbol_grape", "a purple grape cluster slot symbol, shiny 3D fruit icon"),
+        ("symbol_watermelon", "a watermelon wedge slot symbol with juicy shine and clean edges"),
+        ("symbol_orange", "an orange citrus slot symbol, glossy mobile game icon"),
+        ("symbol_bell", "a golden bell slot symbol with fruit stickers and soft glow"),
+        ("symbol_coin", "a shiny golden coin fruit slot symbol"),
+        ("symbol_seven", "a red lucky seven fruit slot symbol with gold trim"),
+    ],
+}
+
+
+UI_ART = [
+    ("bg_main", False, "1024x1536", "vertical mobile slot game background, clear top area for logo, central reel area, bright themed world, no characters, no text"),
+    ("logo", True, "1024x1024", "glossy mobile slot game logo lettering, playful premium game title, thick outline, sparkles, transparent background"),
+    ("reel_frame", True, "1024x1024", "rounded rectangle slot machine reel frame, glowing border, hollow transparent center, clean mobile game UI element"),
+    ("btn_spin", True, "1024x1024", "large round glossy spin button with circular arrows icon, premium 3D mobile game button, transparent background"),
+    ("btn_round", True, "1024x1024", "small round glossy secondary mobile game UI button, blank center, transparent background"),
+    ("hud_pill", True, "1024x1024", "horizontal rounded pill shaped mobile game HUD panel, glossy dark translucent material, blank, transparent background"),
+    ("win_popup", True, "1024x1024", "big win popup frame, glossy gold and candy highlights, blank center, transparent background"),
+    ("free_spin_badge", True, "1024x1024", "free spins bonus badge, glossy premium mobile slot UI, blank center, transparent background"),
+]
+
+
+def slugify(value):
+    value = (value or "slot-game").strip().lower()
+    value = re.sub(r"[^a-z0-9]+", "-", value).strip("-")
+    return value or "slot-game"
+
+
+def clamp_int(value, default, lo, hi):
+    try:
+        value = int(value)
+    except (TypeError, ValueError):
+        value = default
+    return max(lo, min(hi, value))
+
+
+def build_symbol_rules(symbols):
+    count = max(1, len(symbols))
+    rules = []
+    for index, (sid, _prompt) in enumerate(symbols):
+        if sid == "wild" or sid.startswith("wild"):
+            role = "wild"
+            tier = "special"
+            weight = 3
+        elif sid == "scatter" or "scatter" in sid:
+            role = "scatter"
+            tier = "special"
+            weight = 2
+        elif sid in ("coin_cash", "collect") or "coin" in sid:
+            role = "bonus"
+            tier = "bonus"
+            weight = 3
+        else:
+            role = "regular"
+            pct = index / count
+            if pct < 0.2:
+                tier = "premium"
+            elif pct < 0.55:
+                tier = "mid"
+            else:
+                tier = "low"
+            weight = max(6, 22 - index)
+        rules.append({"id": sid, "role": role, "tier": tier, "weight": weight})
+    return rules
+
+
+def build_paytable(symbol_rules, volatility):
+    vol = VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])
+    mult = vol["payMultiplier"]
+    tier_base = {"premium": 10, "mid": 6, "low": 3, "bonus": 2, "special": 0}
+    paytable = {}
+    for item in symbol_rules:
+        base = tier_base.get(item["tier"], 3)
+        if item["role"] == "wild":
+            paytable[item["id"]] = {"3": 0, "4": 0, "5": 0, "6": 0, "note": "substitutes_regular_symbols"}
+        elif item["role"] == "scatter":
+            paytable[item["id"]] = {"3": 4, "4": 12, "5": 40, "6": 100, "note": "pays_anywhere_and_triggers_free_spins"}
+        else:
+            paytable[item["id"]] = {
+                "3": round(base * mult, 2),
+                "4": round(base * 2.4 * mult, 2),
+                "5": round(base * 6.0 * mult, 2),
+                "6": round(base * 14.0 * mult, 2),
+            }
+    return paytable
+
+
+def clamp_float(value, default, lo, hi):
+    try:
+        value = float(value)
+    except (TypeError, ValueError):
+        value = default
+    if value > 1:
+        value = value / 100.0
+    return max(lo, min(hi, value))
+
+
+def build_slot_config(data):
+    theme = data.get("theme", "jelly")
+    reel_mode = data.get("reelMode", "ways")
+    volatility = data.get("volatility", "medium")
+    feature_names = set(data.get("features") or ["cascades", "free_spins", "wilds"])
+    columns = 6 if reel_mode == "megaways" else 5
+    rows = 5 if reel_mode == "cluster" else 3
+    return {
+        "schemaVersion": "slot_game_config.v1",
+        "creative": data.get("creative", {}),
+        "gameDesign": data.get("gameDesign", {}),
+        "game": {
+            "id": slugify(data.get("gameId") or data.get("title") or "jelly-candy-slot"),
+            "title": data.get("title") or "Jelly Candy Slot",
+            "mode": "demo_only",
+            "engine": "cocos_creator_3_8",
+            "orientation": "portrait",
+            "targetViewport": {"width": 798, "height": 1724},
+        },
+        "theme": {
+            "key": theme,
+            "world": THEME_LABELS.get(theme, theme),
+            "visualStyle": data.get("style") or DEFAULT_STYLE_BY_THEME.get(theme, DEFAULT_STYLE_BY_THEME["jelly"]),
+        },
+        "reels": {
+            "mode": reel_mode,
+            "columns": columns,
+            "rows": rows,
+        },
+        "layout": {
+            "viewport": {"width": 798, "height": 1724, "orientation": "portrait"},
+            "logo": {"cy": 0.41, "maxW": 0.82, "maxH": 0.15},
+            "reel": {
+                "cy": 0.02,
+                "h": 0.60,
+                "aspect": 0.652,
+                "holeW": 0.86,
+                "holeH": 0.92,
+                "cols": columns,
+                "rows": rows,
+            },
+            "hud": {"cy": -0.33, "pillH": 0.40},
+            "controls": {"cy": -0.405, "spinR": 0.13, "smallR": 0.075, "xMinus": 0.24, "xTurbo": 0.40},
+            "symbols": {
+                "fitMode": "contain",
+                "targetCellFill": 0.92,
+                "defaultScalePerCell": 0.00093,
+                "defaultOriginYOffsetPerCell": 0.5,
+            },
+        },
+        "mathProfile": {
+            "volatility": volatility,
+            "hitFrequencyFeel": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["hitFrequencyFeel"],
+            "rtpTarget": clamp_float(data.get("targetRtp"), 0.96, 0.5, 0.995),
+            "rtpTargetLabel": "math_model_candidate" if data.get("enableMathModel", True) else "configurable_demo_no_math_model",
+            "enableMathModel": bool(data.get("enableMathModel", True)),
+            "bonusChanceLabel": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["bonusChanceLabel"],
+        },
+        "economy": {
+            "startingBalance": clamp_int(data.get("startingBalance"), 5000, 100, 1000000),
+            "defaultBet": clamp_int(data.get("defaultBet"), 50, 1, 100000),
+            "minBet": 10,
+            "maxBet": 1000,
+            "betSteps": [10, 20, 50, 100, 200, 500, 1000],
+        },
+        "winRules": {
+            "evaluation": "cluster_count" if reel_mode == "cluster" else ("left_to_right_paylines" if reel_mode == "paylines" else "adjacent_ways_demo"),
+            "minMatch": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["minMatch"],
+            "paylines": 20 if reel_mode == "paylines" else 0,
+            "ways": columns ** rows if reel_mode in ("ways", "megaways") else 0,
+        },
+        "features": {
+            "wilds": {"enabled": "wilds" in feature_names, "variant": data.get("wildVariant", "expanding"), "substitutes": "regular"},
+            "scatterFreeSpins": {
+                "enabled": "free_spins" in feature_names,
+                "triggerCount": 3,
+                "awardSpins": clamp_int(data.get("freeSpinCount"), 8, 3, 30),
+                "retriggers": True,
+            },
+            "cascades": {
+                "enabled": "cascades" in feature_names,
+                "maxCascades": clamp_int(data.get("maxCascades"), 6, 1, 20),
+                "multiplierStep": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["cascadeMultiplierStep"],
+                "maxMultiplier": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["maxCascadeMultiplier"],
+            },
+            "holdAndWin": {"enabled": "hold_win" in feature_names, "triggerCount": 6, "respins": 3, "jackpots": ["mini", "minor", "major"]},
+            "multipliers": {"enabled": "multipliers" in feature_names},
+        },
+        "feedback": FEEDBACK_RULES.get(data.get("feedbackIntensity", "standard"), FEEDBACK_RULES["standard"]),
+        "assetGeneration": {
+            "characterCount": int(data.get("characterCount") or 10),
+            "uiCompleteness": data.get("uiCompleteness", "full"),
+            "feedbackIntensity": data.get("feedbackIntensity", "standard"),
+        },
+    }
+
+
+def build_manifest(slot_config):
+    slot_config = copy.deepcopy(slot_config)
+    theme = slot_config["theme"]["key"]
+    style = slot_config["theme"]["visualStyle"]
+    count = slot_config["assetGeneration"]["characterCount"]
+    base_symbols = copy.deepcopy(BASE_SYMBOLS.get(theme, BASE_SYMBOLS["jelly"]))
+    special_symbols = []
+    if slot_config["features"]["wilds"]["enabled"]:
+        special_symbols.append(("wild", "a glossy rainbow WILD slot symbol icon, premium mobile game asset, no small text"))
+    if slot_config["features"]["scatterFreeSpins"]["enabled"]:
+        special_symbols.append(("scatter", "a glowing scatter bonus candy star symbol, premium mobile slot game icon, no small text"))
+    if slot_config["features"]["holdAndWin"]["enabled"]:
+        special_symbols.extend([
+            ("coin_cash", "a shiny golden cash coin slot symbol with soft glow, premium mobile game icon"),
+            ("collect", "a glossy collect symbol with magnet-like gold energy, premium mobile slot game icon"),
+        ])
+    regular_count = max(1, count - len(special_symbols))
+    symbols = base_symbols[:regular_count] + special_symbols
+    symbol_rules = build_symbol_rules(symbols)
+    slot_config["symbols"] = symbol_rules
+    slot_config["paytable"] = build_paytable(symbol_rules, slot_config["mathProfile"]["volatility"])
+    if slot_config.get("mathProfile", {}).get("enableMathModel", True):
+        slot_config["mathModel"] = slot_math.build_math_model(slot_config)
+    else:
+        slot_config["mathModel"] = {
+            "status": "disabled_by_user",
+            "notes": ["Math model generation was disabled for this configurable demo."],
+        }
+
+    vfx = [
+        {"id": "win_burst", "type": "particle", "template": "burst", "color": [255, 180, 80]},
+    ]
+    if slot_config["features"]["cascades"]["enabled"]:
+        vfx.append({"id": "confetti_pop", "type": "particle", "template": "confetti", "color": [255, 120, 200]})
+    if slot_config["features"]["scatterFreeSpins"]["enabled"] or slot_config["features"]["holdAndWin"]["enabled"]:
+        vfx.append({"id": "bigwin_glow", "type": "particle", "template": "glow", "color": [255, 240, 160]})
+    if slot_config["features"]["holdAndWin"]["enabled"] or slot_config["assetGeneration"]["feedbackIntensity"] == "loud":
+        vfx.append({"id": "coin_rain", "type": "particle", "template": "rain", "color": [255, 215, 0]})
+
+    ui = [
+        {"id": "spin_btn_press", "type": "tween", "preset": "scale_bounce"},
+        {"id": "reward_popup_in", "type": "tween", "preset": "elastic_in"},
+        {"id": "panel_slide_in", "type": "tween", "preset": "fade_slide_in", "params": {"dy": 60}},
+        {"id": "balance_roll", "type": "tween", "preset": "number_roll", "params": {"from": 0, "to": 8888, "dur": 0.9}},
+        {"id": "win_icon_pulse", "type": "tween", "preset": "pulse"},
+    ]
+
+    ui_art_ids = {"basic": 6, "full": len(UI_ART)}
+    ui_art_count = ui_art_ids.get(slot_config["assetGeneration"]["uiCompleteness"], len(UI_ART))
+
+    return {
+        "game": slot_config["game"]["id"],
+        "style": style,
+        "slot_config": slot_config,
+        "characters": [
+            {"id": sid, "type": "spine", "animations": ["idle", "win"], "prompt": prompt}
+            for sid, prompt in symbols
+        ],
+        "ui_art": [
+            {"id": aid, "transparent": transparent, "size": size, "prompt": prompt}
+            for aid, transparent, size, prompt in UI_ART[:ui_art_count]
+        ],
+        "vfx": vfx,
+        "ui": ui,
+    }
+
+
+def build_workflow(data):
+    slot_config = build_slot_config(data or {})
+    manifest = build_manifest(slot_config)
+    return {"slot_config": manifest["slot_config"], "manifest": manifest}

+ 267 - 0
spine_builder.py

@@ -0,0 +1,267 @@
+"""把一张角色透明图变成可在 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)}
+
+
+ANIM_FACTORY = {
+    "idle": jiggle_idle,
+    "win": jiggle_win,
+    "attack": attack,
+    "hurt": hurt,
+    "bounce_in": bounce_in,
+}
+
+
+# ---------- 图片处理 ----------
+
+def remove_bg(img, bright=200, sat=38):
+    """自动去背:把"亮且低饱和(白/浅灰/奶油)且与四边连通"的像素抠成透明。
+    用边界连通传播,所以果冻身上被颜色包住的白高光/白眼睛不会被误删。
+    纯 numpy 实现,不依赖 scipy;没装 numpy 时退回 PIL 泛洪。"""
+    img = img.convert("RGBA")
+    try:
+        import numpy as np
+        arr = np.array(img)
+        rgb = arr[:, :, :3].astype(np.int16)
+        mx = rgb.max(axis=2); mn = rgb.min(axis=2)
+        bgcand = (mn >= bright) & ((mx - mn) <= sat)        # 背景候选:亮 + 低饱和
+        # 从靠近四边的一圈带里取种子(最外 1~2px 常是透明黑边,要往里一点)
+        H, W = bgcand.shape
+        band = max(10, H // 80)
+        seed = np.zeros_like(bgcand)
+        seed[:band, :] = True; seed[-band:, :] = True
+        seed[:, :band] = True; seed[:, -band:] = True
+        bg = bgcand & seed
+        for _ in range(2000):                                # 从四边向内连通传播
+            prev = int(bg.sum())
+            new = bg.copy()
+            new[1:, :] |= bg[:-1, :]; new[:-1, :] |= bg[1:, :]
+            new[:, 1:] |= bg[:, :-1]; new[:, :-1] |= bg[:, 1:]
+            new &= bgcand
+            bg = new
+            if int(bg.sum()) == prev:
+                break
+        # 羽化一圈,柔化果冻边缘的白色硬边
+        edge = bg.copy()
+        edge[1:, :] |= bg[:-1, :]; edge[:-1, :] |= bg[1:, :]
+        edge[:, 1:] |= bg[:, :-1]; edge[:, :-1] |= bg[:, 1:]
+        fringe = edge & ~bg & (mn >= bright - 30)
+        arr[bg, 3] = 0
+        arr[fringe, 3] = (arr[fringe, 3] * 0.35).astype(arr.dtype)
+        return Image.fromarray(arr, "RGBA")
+    except Exception:
+        return _floodfill_bg(img, 236)
+
+
+def _floodfill_bg(img, thresh):
+    """纯 PIL 兜底:先把透明区垫白(否则透明边会被当种子),再从四边泛洪近白背景。"""
+    from PIL import ImageDraw
+    w, h = img.size
+    rgb = Image.new("RGB", (w, h), (255, 255, 255))
+    rgb.paste(img.convert("RGB"), mask=img.split()[3])   # 用 alpha 贴,透明处保持白
+    tol = 255 - thresh + 10
+    seeds = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1),
+             (w // 2, 0), (w // 2, h - 1), (0, h // 2), (w - 1, h // 2)]
+    for s in seeds:
+        ImageDraw.floodfill(rgb, s, (255, 0, 255), thresh=tol)
+    try:
+        import numpy as np
+        mask = (np.array(rgb) == (255, 0, 255)).all(axis=2)
+        arr = np.array(img.convert("RGBA"))
+        arr[mask, 3] = 0
+        return Image.fromarray(arr, "RGBA")
+    except Exception:
+        out = img.convert("RGBA"); po, pr = out.load(), rgb.load()
+        for y in range(h):
+            for x in range(w):
+                if pr[x, y] == (255, 0, 255):
+                    r, g, b, _ = po[x, y]; po[x, y] = (r, g, b, 0)
+        return out
+
+
+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 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(remove_bg(image))   # 先自动去白底,再裁透明边
+    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

+ 704 - 0
templates/SlotGame.ts

@@ -0,0 +1,704 @@
+// =============================================================
+//  SlotGame.ts —— 《果冻消消乐 · Jelly Pop》消除式老虎机(可玩原型)
+//  by anim_studio
+//  v4:先预加载全部 UI 美术,再搭界面——HUD 胶囊用 hud_pill、
+//      圆按钮用 btn_round、SPIN 用 btn_spin;隐藏预览性能面板;
+//      4×5 大符号 + 每格圆角卡片。
+// =============================================================
+import {
+  _decorator, Component, Node, sp, resources, JsonAsset, SpriteFrame, Sprite,
+  ParticleSystem2D, UITransform, Label, Graphics, Button, Color, Vec3,
+  view, EventTouch, tween, UIOpacity,
+} from 'cc';
+import * as cc from 'cc';
+import { applyParticleConfig } from './ParticleConfig';
+
+const { ccclass } = _decorator;
+
+const SYMBOLS = __SYMBOLS__;
+const GENERATED_CONFIG = __GAME_CONFIG__;
+
+// ============ 画面结构(先定区域,再往里填元素)============
+// 所有数值都是相对屏幕的比例;cy = 区域中心相对屏幕中心的 Y(向上为正)。
+const LAYOUT = (GENERATED_CONFIG.layout || {
+  logo:     { cy: 0.41, maxW: 0.82, maxH: 0.15 },                   // 顶部 Logo
+  reel:     { cy: 0.02, h: 0.60, aspect: 0.652,                     // 卷轴框(按新图:霓虹边+白板)
+              holeW: 0.86, holeH: 0.92, cols: 4, rows: 6 },         //   白板内孔 + 网格行列
+  hud:      { cy: -0.33, pillH: 0.40 },                             // 三个 HUD 胶囊(按图比例)
+  controls: { cy: -0.405, spinR: 0.13, smallR: 0.075,              // 控制按钮(按宽度定标、做大)
+              xMinus: 0.24, xTurbo: 0.40 },
+});
+const ECONOMY = GENERATED_CONFIG.economy || { startingBalance: 5000, defaultBet: 50 };
+const WIN_RULES = GENERATED_CONFIG.winRules || { minMatch: 4 };
+const FEATURE_RULES = GENERATED_CONFIG.features || {};
+const FEEDBACK_CONFIG = GENERATED_CONFIG.feedback || {};
+const PAYTABLE = GENERATED_CONFIG.paytable || {};
+const SYMBOL_RULES = GENERATED_CONFIG.symbols || [];
+const MATH_MODEL = GENERATED_CONFIG.mathModel || {};
+const PAYOUT_SCALE = MATH_MODEL.payoutScale ?? 1;
+const REEL_STRIPS = MATH_MODEL.reelStrips || [];
+
+// ============ 文字样式表(统一定义,按角色取用)============
+// size<=1 表示相对所在容器高度的比例;否则按像素。ow=描边宽。
+const STYLE = {
+  hudTitle:  { size: 0.30, color: [205, 222, 255], bold: false, outline: [40, 28, 78], ow: 0, letter: 0.04 },
+  hudValue:  { size: 0.52, color: [255, 255, 255], bold: true,  outline: [54, 30, 96], ow: 3 },
+  mult:      { size: 66,   color: [255, 214, 70],  bold: true,  outline: [188, 70, 24], ow: 6 },
+  floatWin:  { size: 46,   color: [255, 244, 130], bold: true,  outline: [196, 92, 24], ow: 5 },
+  bigWin:    { size: 0.16,  color: [255, 224, 70],  bold: true,  outline: [180, 50, 16], ow: 9 },  // 报幕(相对屏宽)
+};
+
+// ============ 每个符号的"按内容定标"参数(s=缩放/格, oyf=居中下移/格)============
+// 由素材实际内容包围盒算出,保证果冻填满格子、垂直居中——这就是"按规格画/摆"。
+const SYMFIT: Record<string, {s: number; oyf: number}> = __SYMBOL_FIT__;
+const SYMFIT_DEFAULT = __SYMBOL_FIT_DEFAULT__;
+
+// ============ 交互/动效反馈(统一定义)============
+const FEEDBACK = {
+  btnPress: 0.86,
+  btnBack: 0.16,
+  idleBob: 0.016,
+  dropFrom: 1.05,
+  dropTime: FEEDBACK_CONFIG.dropTimeSec ?? 0.30,
+  clearPop: FEEDBACK_CONFIG.clearPopScale ?? 1.30,
+  spinDuration: FEEDBACK_CONFIG.spinDurationSec ?? 0.45,
+  bigWinThresholdBet: FEEDBACK_CONFIG.bigWinThresholdBet ?? 25,
+  screenShake: FEEDBACK_CONFIG.screenShake ?? false,
+};
+
+const COLS = LAYOUT.reel.cols, ROWS = LAYOUT.reel.rows;
+const MIN_MATCH = WIN_RULES.minMatch ?? 4;
+const CASCADE_RULES = FEATURE_RULES.cascades || {};
+const MAX_CASCADE = CASCADE_RULES.maxCascades ?? 6;
+const CASCADE_MULT_STEP = CASCADE_RULES.multiplierStep ?? 1;
+const CASCADE_MULT_MAX = CASCADE_RULES.maxMultiplier ?? 6;
+const START_BALANCE = ECONOMY.startingBalance ?? 5000;
+const DEFAULT_BET = ECONOMY.defaultBet ?? 50;
+const SCATTER_RULES = FEATURE_RULES.scatterFreeSpins || {};
+const HOLD_RULES = FEATURE_RULES.holdAndWin || {};
+const IS_CLUSTER = WIN_RULES.evaluation === 'cluster_count';
+const FREE_SPIN_TRIGGER = SCATTER_RULES.triggerCount ?? 3;
+const FREE_SPIN_AWARD = SCATTER_RULES.awardSpins ?? 8;
+const HOLD_TRIGGER = HOLD_RULES.triggerCount ?? 6;
+const HOLD_RESPINS = HOLD_RULES.respins ?? 3;
+const SYMBOL_ROLE: Record<string, string> = {};
+SYMBOL_RULES.forEach((s: any) => { SYMBOL_ROLE[s.id] = s.role; });
+
+@ccclass('SlotGame')
+export class SlotGame extends Component {
+  private dataMap: Record<string, sp.SkeletonData> = {};
+  private art: Record<string, SpriteFrame | null> = {};
+  private cells: sp.Skeleton[][] = [];
+  private ids: string[][] = [];
+
+  private cell = 90;
+  private gridX0 = 0;
+  private gridY0 = 0;
+
+  private spinning = false;
+  private balance = START_BALANCE;
+  private displayBalance = START_BALANCE;
+  private bet = DEFAULT_BET;
+  private multiplier = 1;
+  private roundWin = 0;
+  private cascade = 0;
+  private freeSpins = 0;
+  private holdActive = false;
+  private holdRespinsLeft = 0;
+  private holdHeld: boolean[][] = [];
+  private holdWinTotal = 0;
+
+  private balanceLabel!: Label;
+  private betLabel!: Label;
+  private winLabel!: Label;
+  private multLabel!: Label;
+  private spinBtn!: Node;
+  private particleTex: SpriteFrame | null = null;
+  private frameCY = 0; private frameW = 0; private frameH = 0;
+  private W = 0; private H = 0;
+  private symBaseY: number[][] = [];
+  private t = 0;
+  private turbo = false; private auto = false;
+  private turboBtn!: Node; private autoBtn!: Node;
+
+  onLoad() {
+    try { (cc as any).profiler?.hideStats?.(); } catch (e) {}
+    this.node.setPosition(0, 0, 0);
+    const ut = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
+    ut.setAnchorPoint(0.5, 0.5);
+    const s = view.getVisibleSize();
+    this.W = s.width; this.H = s.height;
+    ut.setContentSize(s.width, s.height);
+    this.computeLayout(s.width, s.height);
+    this.buildBackground(s.width, s.height);     // 渐变兜底
+
+    resources.load('vfx/particle/spriteFrame', SpriteFrame, (_e, sf) => { if (sf) this.particleTex = sf; });
+
+    const artIds = ['bg_main', 'reel_frame', 'logo', 'hud_pill', 'btn_round', 'btn_spin', 'btn_plus', 'btn_minus', 'btn_turbo', 'btn_auto', 'coin'];
+    let toLoad = artIds.length;
+    artIds.forEach((id) => {
+      resources.load(`ui_art/${id}/spriteFrame`, SpriteFrame, (err, sf) => {
+        this.art[id] = err ? null : sf;
+        if (--toLoad === 0) this.afterArt();
+      });
+    });
+  }
+
+  private afterArt() {
+    const W = this.W, H = this.H;
+    this.buildArt(W, H);
+    this.buildHud(W, H);
+    this.buildControls(W, H);
+    let left = SYMBOLS.length;
+    SYMBOLS.forEach((id) => {
+      resources.load(`characters/${id}`, sp.SkeletonData, (err, data) => {
+        if (!err) this.dataMap[id] = data;
+        if (--left === 0) this.buildGrid();
+      });
+    });
+  }
+
+  private computeLayout(W: number, H: number) {
+    const R = LAYOUT.reel;
+    let fh = H * R.h;
+    let fw = fh * R.aspect;
+    if (fw > W * 0.96) { fw = W * 0.96; fh = fw / R.aspect; }
+    this.frameW = fw; this.frameH = fh;
+    this.frameCY = H * R.cy;
+    const innerW = fw * R.holeW, innerH = fh * R.holeH;
+    this.cell = Math.min(innerW / COLS, innerH / ROWS);
+    this.gridX0 = -((COLS - 1) * this.cell) / 2;
+    this.gridY0 = this.frameCY + ((ROWS - 1) * this.cell) / 2;
+  }
+  private cellPos(c: number, r: number): [number, number] {
+    return [this.gridX0 + c * this.cell, this.gridY0 - r * this.cell];
+  }
+
+  private buildBackground(W: number, H: number) {
+    const n = new Node('bg'); n.parent = this.node; n.setSiblingIndex(0);
+    const g = n.addComponent(Graphics);
+    g.fillColor = new Color(120, 200, 255, 255); g.rect(-W / 2, 0, W, H / 2); g.fill();
+    g.fillColor = new Color(255, 150, 175, 255); g.rect(-W / 2, -H / 2, W, H / 2); g.fill();
+  }
+
+  // ---- 背景大图 / 卷轴框 / Logo(有图用图,没图兜底)----
+  private buildArt(W: number, H: number) {
+    if (this.art['bg_main']) {
+      const [w, h] = this.coverSize(this.art['bg_main']!, W, H);
+      this.spriteNode(this.art['bg_main']!, w, h, 0, 0, 1);
+    }
+    if (this.art['reel_frame']) {
+      this.spriteNode(this.art['reel_frame']!, this.frameW, this.frameH, 0, this.frameCY, 3);
+    } else {
+      const n = new Node('frame'); n.parent = this.node; n.setPosition(0, this.frameCY, 0); n.setSiblingIndex(3);
+      const g = n.addComponent(Graphics);
+      g.lineWidth = 10; g.strokeColor = new Color(120, 180, 255, 255);
+      this.roundRect(g, -this.frameW / 2, -this.frameH / 2, this.frameW, this.frameH, 26); g.stroke();
+    }
+    const logoCy = H * LAYOUT.logo.cy;
+    if (this.art['logo']) {
+      const [w, h] = this.fitSize(this.art['logo']!, W * LAYOUT.logo.maxW, H * LAYOUT.logo.maxH);
+      this.spriteNode(this.art['logo']!, w, h, 0, logoCy, 21);
+    } else {
+      const t = new Node('logo'); t.parent = this.node; t.setPosition(0, logoCy, 0); t.setSiblingIndex(21);
+      const lab = t.addComponent(Label); lab.string = '🍬 JELLY POP'; lab.fontSize = 42; lab.color = new Color(255, 90, 70, 255);
+    }
+  }
+
+  // ---------------- HUD:用 hud_pill 美术 ----------------
+  private buildHud(W: number, H: number) {
+    const y = H * LAYOUT.hud.cy;
+    const pw = (W - 36) / 3 - 8, gap = 8;
+    const ps = this.art['hud_pill'];
+    const ph = ps ? pw * (ps.rect.height / ps.rect.width) : pw * LAYOUT.hud.pillH;   // 按图比例,不拉伸
+    this.balanceLabel = this.makePill(-(pw + gap), y, pw, ph, '余额', `${START_BALANCE}`);
+    this.betLabel = this.makePill(0, y, pw, ph, '下注', `${this.bet}`);
+    this.winLabel = this.makePill(pw + gap, y, pw, ph, '本局赢', '0');
+    const m = new Node('mult'); m.parent = this.node; m.setPosition(0, this.frameCY, 0); m.setSiblingIndex(25);
+    this.multLabel = m.addComponent(Label); this.applyStyle(this.multLabel, STYLE.mult, 0); this.multLabel.string = '';
+  }
+
+  private makePill(x: number, y: number, w: number, h: number, title: string, val: string): Label {
+    const n = new Node('pill'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(16);
+    if (this.art['hud_pill']) {
+      const s = n.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = this.art['hud_pill']!;
+    } else {
+      const g = n.addComponent(Graphics); g.fillColor = new Color(70, 50, 120, 235);
+      this.roundRect(g, -w / 2, -h / 2, w, h, h / 2); g.fill();
+    }
+    const ut = n.getComponent(UITransform) || n.addComponent(UITransform); ut.setContentSize(w, h);
+    // 标题(小字,靠左)
+    const tl = new Node('t'); tl.parent = n; tl.setPosition(-w * 0.30, 0, 0);
+    const tlab = tl.addComponent(Label); tlab.string = title; this.applyStyle(tlab, STYLE.hudTitle, h);
+    // 数值(大字带描边,靠右)
+    const vl = new Node('v'); vl.parent = n; vl.setPosition(w * 0.12, 0, 0);
+    const lab = vl.addComponent(Label); lab.string = val; this.applyStyle(lab, STYLE.hudValue, h);
+    return lab;
+  }
+
+  // 统一文字样式
+  private applyStyle(lab: Label, st: any, base: number) {
+    lab.fontSize = st.size <= 1 ? Math.round(base * st.size) : st.size;
+    lab.lineHeight = lab.fontSize;
+    lab.color = new Color(st.color[0], st.color[1], st.color[2], 255);
+    if (st.bold) lab.isBold = true;
+    if (st.ow && st.ow > 0) {
+      lab.enableOutline = true;
+      lab.outlineColor = new Color(st.outline[0], st.outline[1], st.outline[2], 255);
+      lab.outlineWidth = st.ow;
+    }
+  }
+
+  // 数字变化时弹一下
+  private pop(node: Node, s = 1.22) {
+    node.setScale(s, s, 1);
+    tween(node).to(0.22, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' }).start();
+  }
+
+  // 中奖时在网格中央飘一个 +N 上升淡出
+  private floatWin(amount: number) {
+    const n = new Node('fw'); n.parent = this.node; n.setPosition(0, this.frameCY + this.cell, 0); n.setSiblingIndex(27);
+    const lab = n.addComponent(Label); lab.string = '+' + amount; this.applyStyle(lab, STYLE.floatWin, 0);
+    const op = n.addComponent(UIOpacity);
+    n.setScale(0.5, 0.5, 1);
+    tween(n).to(0.16, { scale: new Vec3(1.15, 1.15, 1) }, { easing: 'backOut' })
+            .to(0.12, { scale: new Vec3(1, 1, 1) }).start();
+    tween(n).by(0.85, { position: new Vec3(0, this.cell * 1.6, 0) }, { easing: 'cubicOut' }).start();
+    tween(op).delay(0.35).to(0.5, { opacity: 0 }).call(() => n.destroy()).start();
+  }
+
+  // ---------------- 控制区:btn_round / btn_spin 美术 ----------------
+  private buildControls(W: number, H: number) {
+    const C = LAYOUT.controls;
+    const y = H * C.cy;
+    const big = W * C.spinR;       // SPIN 按屏宽定标,最突出
+    const sr = W * C.smallR;
+    this.spinBtn = this.makeBtn(0, y, big, 'btn_spin', '⟳', big * 0.6, () => this.spin());
+    this.turboBtn = this.makeBtn(-W * C.xTurbo, y, sr, 'btn_turbo', '⚡', sr * 0.8, () => this.toggleTurbo());
+    this.makeBtn(-W * C.xMinus, y, sr, 'btn_minus', '−', sr, () => this.changeBet(-10));
+    this.makeBtn(W * C.xMinus, y, sr, 'btn_plus', '+', sr, () => this.changeBet(10));
+    this.autoBtn = this.makeBtn(W * C.xTurbo, y, sr, 'btn_auto', '▶', sr * 0.8, () => this.toggleAuto());
+  }
+
+  private makeBtn(x: number, y: number, r: number, artId: string, glyph: string, fs: number, onClick: () => void): Node {
+    const node = new Node('btn'); node.parent = this.node; node.setPosition(x, y, 0); node.setSiblingIndex(17);
+    let hasArt = false;
+    if (this.art[artId]) {
+      const s = node.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = this.art[artId]!; hasArt = true;
+    } else {
+      const g = node.addComponent(Graphics); g.fillColor = new Color(90, 70, 140, 255); g.circle(0, 0, r); g.fill();
+      g.lineWidth = 4; g.strokeColor = new Color(255, 255, 255, 235); g.circle(0, 0, r); g.stroke();
+    }
+    const ut = node.getComponent(UITransform) || node.addComponent(UITransform); ut.setContentSize(r * 2, r * 2);
+    // 有美术(图标已烤进图里)就不叠字;没有才用文字兜底
+    if (!hasArt) {
+      const gl = new Node('g'); gl.parent = node;
+      const lab = gl.addComponent(Label); lab.string = glyph; lab.fontSize = fs; lab.color = new Color(255, 255, 255, 255);
+    }
+    const btn = node.addComponent(Button);
+    btn.transition = Button.Transition.NONE;   // 自己控制反馈,更跟手
+    const press = () => { tween(node).to(0.07, { scale: new Vec3(FEEDBACK.btnPress, FEEDBACK.btnPress, 1) }).start(); };
+    const release = () => { tween(node).to(FEEDBACK.btnBack, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' }).start(); };
+    node.on(Node.EventType.TOUCH_START, press);
+    node.on(Node.EventType.TOUCH_END, (_e: EventTouch) => { release(); onClick(); });
+    node.on(Node.EventType.TOUCH_CANCEL, release);
+    return node;
+  }
+
+  private makeTextLabel(x: number, y: number, text: string, fs: number) {
+    const n = new Node('tl'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(17);
+    const lab = n.addComponent(Label); lab.string = text; lab.fontSize = fs; lab.color = new Color(255, 255, 255, 235);
+  }
+
+  private changeBet(d: number) {
+    if (this.spinning) return;
+    this.bet = Math.max(10, Math.min(500, this.bet + d));
+    this.betLabel.string = `${this.bet}`;
+    this.pop(this.betLabel.node);
+  }
+
+  // TURBO:加速;AUTO:自动连转。带高亮状态。
+  private td(v: number) { return this.turbo ? v * 0.4 : v; }
+  private toggleTurbo() {
+    this.turbo = !this.turbo; this.tintBtn(this.turboBtn, this.turbo);
+  }
+  private toggleAuto() {
+    this.auto = !this.auto; this.tintBtn(this.autoBtn, this.auto);
+    if (this.auto && !this.spinning) this.spin();
+  }
+  private tintBtn(node: Node, on: boolean) {
+    const s = node.getComponent(Sprite);
+    if (s) s.color = on ? new Color(255, 224, 90, 255) : new Color(255, 255, 255, 255);
+    tween(node).to(0.12, { scale: new Vec3(on ? 1.12 : 1, on ? 1.12 : 1, 1) }, { easing: 'backOut' }).start();
+  }
+
+  // 大额报幕 + 金币雨
+  private celebrate(amount: number) {
+    const n = new Node('bigwin'); n.parent = this.node; n.setPosition(0, this.frameCY, 0);
+    n.setSiblingIndex(this.node.children.length);   // 强制置顶
+    const lab = n.addComponent(Label); lab.string = '+' + amount; this.applyStyle(lab, STYLE.bigWin, this.W);
+    const op = n.addComponent(UIOpacity);
+    n.setScale(0.2, 0.2, 1);
+    tween(n).to(0.28, { scale: new Vec3(1.18, 1.18, 1) }, { easing: 'backOut' })
+            .to(0.12, { scale: new Vec3(1, 1, 1) })
+            .delay(0.6).to(0.25, { scale: new Vec3(1.25, 1.25, 1) }).start();
+    tween(op).delay(0.95).to(0.35, { opacity: 0 }).call(() => n.destroy()).start();
+    this.coinShower(Math.max(12, Math.min(40, Math.floor(amount / 15))));
+  }
+  private coinShower(count: number) {
+    const sf = this.art['coin']; if (!sf) return;
+    const sz = this.cell * 0.6;
+    for (let i = 0; i < count; i++) {
+      const x0 = (Math.random() * 2 - 1) * this.frameW * 0.42;
+      const c = this.spriteNode(sf, sz, sz, x0, this.H * 0.5 + this.cell, this.node.children.length + 1);
+      const op = c.addComponent(UIOpacity);
+      const dur = 0.9 + Math.random() * 0.7;
+      const dx = (Math.random() * 2 - 1) * this.W * 0.12;
+      tween(c).delay(Math.random() * 0.35)
+              .by(dur, { position: new Vec3(dx, -this.H - this.cell * 2, 0), angle: (Math.random() * 2 - 1) * 720 }, { easing: 'sineIn' })
+              .call(() => c.destroy()).start();
+      tween(op).delay(dur * 0.7).to(dur * 0.3, { opacity: 0 }).start();
+    }
+  }
+
+  private fitSize(sf: SpriteFrame, boxW: number, boxH: number): [number, number] {
+    const r = sf.rect; const aw = r.width || 1, ah = r.height || 1;
+    const s = Math.min(boxW / aw, boxH / ah); return [aw * s, ah * s];
+  }
+  private coverSize(sf: SpriteFrame, boxW: number, boxH: number): [number, number] {
+    const r = sf.rect; const aw = r.width || 1, ah = r.height || 1;
+    const s = Math.max(boxW / aw, boxH / ah); return [aw * s, ah * s];
+  }
+  private spriteNode(sf: SpriteFrame, w: number, h: number, x: number, y: number, sib: number): Node {
+    const n = new Node('art'); n.parent = this.node; n.setPosition(x, y, 0); n.setSiblingIndex(sib);
+    const s = n.addComponent(Sprite); s.sizeMode = Sprite.SizeMode.CUSTOM; s.spriteFrame = sf;
+    const ut = n.getComponent(UITransform) || n.addComponent(UITransform); ut.setContentSize(w, h);
+    return n;
+  }
+
+  // ---------------- 网格:圆角卡片 + 大符号 ----------------
+  private fit(id: string) { return SYMFIT[id] || SYMFIT_DEFAULT; }
+
+  private buildGrid() {
+    const cardSize = this.cell * 0.92;
+    for (let c = 0; c < COLS; c++) {
+      this.cells[c] = []; this.ids[c] = []; this.symBaseY[c] = [];
+      for (let r = 0; r < ROWS; r++) {
+        const id = this.reelRand(c);
+        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);
+        this.roundRect(cg, -cardSize / 2, -cardSize / 2, cardSize, cardSize, cardSize * 0.24); cg.fill();
+        cg.lineWidth = 3; cg.strokeColor = new Color(120, 175, 255, 240);
+        this.roundRect(cg, -cardSize / 2, -cardSize / 2, cardSize, cardSize, cardSize * 0.24); cg.stroke();
+        const f = this.fit(id);
+        const scale = this.cell * f.s;
+        const baseY = y - this.cell * f.oyf;
+        this.symBaseY[c][r] = baseY;
+        const node = new Node(`cell_${c}_${r}`); node.parent = this.node; node.setSiblingIndex(6);
+        node.setPosition(x, baseY, 0); node.setScale(scale, scale, 1);
+        const sk = node.addComponent(sp.Skeleton);
+        sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false;
+        sk.setAnimation(0, 'idle', true);
+        this.cells[c][r] = sk; this.ids[c][r] = id;
+      }
+    }
+  }
+
+  private setSym(c: number, r: number, id: string) {
+    const sk = this.cells[c][r];
+    sk.skeletonData = this.dataMap[id]; sk.premultipliedAlpha = false;
+    sk.setAnimation(0, 'idle', true);
+    this.ids[c][r] = id;
+    const [x, y] = this.cellPos(c, r);
+    const f = this.fit(id);
+    const scale = this.cell * f.s;
+    const baseY = y - this.cell * f.oyf;
+    this.symBaseY[c][r] = baseY;
+    const nd = sk.node;
+    nd.setScale(scale, scale, 1);
+    nd.setPosition(x, baseY + this.cell * FEEDBACK.dropFrom, 0);   // 从上方落入
+    tween(nd).to(FEEDBACK.dropTime, { position: new Vec3(x, baseY, 0) }, { easing: 'backOut' }).start();
+  }
+
+  private spin() {
+    if (this.spinning) return;
+    if (this.holdActive) { this.holdRespin(); return; }
+    const freeSpin = this.freeSpins > 0;
+    if (!freeSpin && this.balance < this.bet) { this.flashMult('余额不足'); return; }
+    if (freeSpin) {
+      this.freeSpins -= 1;
+      this.flashMult(`免费旋转 ${this.freeSpins}`);
+    } else {
+      this.balance -= this.bet; this.displayBalance = this.balance;
+      this.balanceLabel.string = `${Math.floor(this.displayBalance)}`;
+      this.pop(this.balanceLabel.node);
+    }
+    this.multiplier = 1; this.roundWin = 0; this.cascade = 0;
+    this.winLabel.string = '0'; this.multLabel.string = '';
+    this.spinning = true; this.setSpinEnabled(false);
+    tween(this.spinBtn).by(FEEDBACK.spinDuration, { angle: -360 }, { easing: 'cubicOut' }).start();   // 联动旋转
+    for (let c = 0; c < COLS; c++) {
+      const strip = this.reel(c);
+      const start = Math.floor(Math.random() * strip.length);
+      for (let r = 0; r < ROWS; r++) this.setSym(c, r, strip[(start + r) % strip.length]);
+    }
+    this.scheduleOnce(() => this.resolve(), this.td(0.45));
+  }
+
+  private resolve() {
+    if (this.cascade === 0) this.resolveFeatureTriggers();
+    const count: Record<string, number> = {};
+    for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) count[this.ids[c][r]] = (count[this.ids[c][r]] || 0) + 1;
+    const result = IS_CLUSTER ? this.findClusterWins() : this.findCountWins(count);
+    if (result.groups.length === 0) { this.endRound(); return; }
+    let cleared = 0; const winners: sp.Skeleton[] = [];
+    for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
+      if (result.mask[c] && result.mask[c][r]) { winners.push(this.cells[c][r]); cleared++; }
+    }
+    const pay = this.calcPay(result.groups);
+    this.roundWin += pay; this.balance += pay; this.winLabel.string = `${this.roundWin}`;
+    this.pop(this.winLabel.node, 1.3); this.floatWin(pay);
+    this.flashMult(`x${this.multiplier}  +${pay}`);
+    winners.forEach((sk) => {
+      sk.setAnimation(0, 'win', false); sk.addAnimation(0, 'idle', true, 0);
+      const nd = sk.node; const sc = nd.scale.x;
+      tween(nd).to(0.1, { scale: new Vec3(sc * FEEDBACK.clearPop, sc * FEEDBACK.clearPop, 1) })
+               .to(0.14, { scale: new Vec3(sc, sc, 1) }, { easing: 'backOut' }).start();
+    });
+    this.playParticle('coin_rain');
+    this.cascade += 1;
+      this.scheduleOnce(() => {
+      for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++)
+        if (result.mask[c] && result.mask[c][r]) this.setSym(c, r, this.reelRand(c));
+      this.multiplier = Math.min(CASCADE_MULT_MAX, this.multiplier + CASCADE_MULT_STEP);
+      if (this.cascade >= MAX_CASCADE) { this.endRound(); return; }
+      this.scheduleOnce(() => this.resolve(), this.td(0.4));
+    }, this.td(0.6));
+  }
+
+  private calcPay(groups: {id: string; n: number}[]) {
+    let total = 0;
+    groups.forEach(({id, n}) => {
+      const table = PAYTABLE[id] || {};
+      const capped = Math.min(6, Math.max(MIN_MATCH, n));
+      const mult = Number(table[String(capped)] ?? table[String(MIN_MATCH)] ?? capped);
+      total += mult * this.bet;
+    });
+    return Math.max(1, Math.floor(total * this.multiplier * PAYOUT_SCALE));
+  }
+
+  private resolveFeatureTriggers() {
+    if (SCATTER_RULES.enabled) {
+      const scatterCount = this.countRole('scatter');
+      if (scatterCount >= FREE_SPIN_TRIGGER) {
+        const scatterPay = this.featurePay('scatter', scatterCount);
+        this.freeSpins += FREE_SPIN_AWARD;
+        if (scatterPay > 0) { this.roundWin += scatterPay; this.balance += scatterPay; this.winLabel.string = `${this.roundWin}`; }
+        this.flashMult(`FREE SPINS +${FREE_SPIN_AWARD}`);
+        this.playParticle('bigwin_glow');
+      }
+    }
+    if (HOLD_RULES.enabled && !this.holdActive && this.countRole('bonus') >= HOLD_TRIGGER) {
+      this.startHoldAndWin();
+    }
+  }
+
+  private findCountWins(count: Record<string, number>) {
+    const wildCount = this.countRole('wild');
+    const mask = this.emptyMask();
+    const groups = Object.keys(count).filter((id) => {
+      if (this.isWild(id) || this.isScatter(id) || this.isBonus(id)) return false;
+      return (count[id] || 0) + wildCount >= MIN_MATCH;
+    }).map((id) => ({id, n: (count[id] || 0) + wildCount}));
+    for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
+      const id = this.ids[c][r];
+      if (groups.some((g) => g.id === id) || (this.isWild(id) && groups.length > 0)) mask[c][r] = true;
+    }
+    return {groups, mask};
+  }
+
+  private findClusterWins() {
+    const groups: {id: string; n: number}[] = [];
+    const mask = this.emptyMask();
+    const regular = SYMBOLS.filter((id: string) => !this.isWild(id) && !this.isScatter(id) && !this.isBonus(id));
+    regular.forEach((sid: string) => {
+      const seen = this.emptyMask();
+      for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
+        if (seen[c][r] || (this.ids[c][r] !== sid && !this.isWild(this.ids[c][r]))) continue;
+        const stack: [number, number][] = [[c, r]];
+        const cells: [number, number][] = [];
+        seen[c][r] = true;
+        while (stack.length) {
+          const [x, y] = stack.pop()!;
+          cells.push([x, y]);
+          [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]].forEach(([nx, ny]) => {
+            if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS || seen[nx][ny]) return;
+            const nid = this.ids[nx][ny];
+            if (nid === sid || this.isWild(nid)) { seen[nx][ny] = true; stack.push([nx, ny]); }
+          });
+        }
+        if (cells.length >= MIN_MATCH) {
+          groups.push({id: sid, n: cells.length});
+          cells.forEach(([x, y]) => { mask[x][y] = true; });
+        }
+      }
+    });
+    return {groups, mask};
+  }
+
+  private emptyMask() {
+    const mask: boolean[][] = [];
+    for (let c = 0; c < COLS; c++) {
+      mask[c] = [];
+      for (let r = 0; r < ROWS; r++) mask[c][r] = false;
+    }
+    return mask;
+  }
+
+  private featurePay(id: string, count: number) {
+    const table = PAYTABLE[id] || {};
+    const n = Math.min(6, Math.max(3, count));
+    return Math.floor(Number(table[String(n)] ?? 0) * this.bet * PAYOUT_SCALE);
+  }
+
+  private role(id: string) { return SYMBOL_ROLE[id] || ''; }
+  private isWild(id: string) { return this.role(id) === 'wild' || id === 'wild'; }
+  private isScatter(id: string) { return this.role(id) === 'scatter' || id.indexOf('scatter') >= 0; }
+  private isBonus(id: string) { return this.role(id) === 'bonus' || id.indexOf('coin') >= 0 || id === 'collect'; }
+  private countRole(role: string) {
+    let n = 0;
+    for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
+      const id = this.ids[c][r];
+      if ((role === 'wild' && this.isWild(id)) || (role === 'scatter' && this.isScatter(id)) || (role === 'bonus' && this.isBonus(id))) n++;
+    }
+    return n;
+  }
+
+  private startHoldAndWin() {
+    this.holdActive = true;
+    this.holdRespinsLeft = HOLD_RESPINS;
+    this.holdWinTotal = 0;
+    this.holdHeld = [];
+    for (let c = 0; c < COLS; c++) {
+      this.holdHeld[c] = [];
+      for (let r = 0; r < ROWS; r++) {
+        const held = this.isBonus(this.ids[c][r]);
+        this.holdHeld[c][r] = held;
+        if (held) this.holdWinTotal += this.coinValue(c, r);
+      }
+    }
+    this.flashMult(`HOLD & WIN ${this.holdRespinsLeft}`);
+    this.playParticle('bigwin_glow');
+  }
+
+  private holdRespin() {
+    this.spinning = true; this.setSpinEnabled(false);
+    let newCoin = false;
+    for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) {
+      if (this.holdHeld[c] && this.holdHeld[c][r]) continue;
+      const id = Math.random() < 0.18 ? this.randomBonusSymbol() : this.randRegular();
+      this.setSym(c, r, id);
+      if (this.isBonus(id)) {
+        this.holdHeld[c][r] = true;
+        this.holdWinTotal += this.coinValue(c, r);
+        newCoin = true;
+      }
+    }
+    this.holdRespinsLeft = newCoin ? HOLD_RESPINS : this.holdRespinsLeft - 1;
+    this.flashMult(`HOLD & WIN ${this.holdRespinsLeft}`);
+    this.scheduleOnce(() => {
+      if (this.holdRespinsLeft <= 0 || this.allHoldFilled()) {
+        this.balance += this.holdWinTotal; this.roundWin += this.holdWinTotal; this.winLabel.string = `${this.roundWin}`;
+        this.holdActive = false; this.holdHeld = [];
+        this.floatWin(this.holdWinTotal); this.playParticle('coin_rain');
+        this.endRound();
+      } else {
+        this.spinning = false; this.setSpinEnabled(true);
+      }
+    }, this.td(0.6));
+  }
+
+  private allHoldFilled() {
+    for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS; r++) if (!this.holdHeld[c][r]) return false;
+    return true;
+  }
+
+  private coinValue(c: number, r: number) { return Math.floor(this.bet * (1 + ((c + r) % 5)) * PAYOUT_SCALE); }
+  private randomBonusSymbol() {
+    const bonus = SYMBOLS.filter((id: string) => this.isBonus(id));
+    return bonus.length ? bonus[Math.floor(Math.random() * bonus.length)] : this.rand();
+  }
+  private randRegular() {
+    const regular = SYMBOLS.filter((id: string) => !this.isWild(id) && !this.isScatter(id) && !this.isBonus(id));
+    return regular.length ? regular[Math.floor(Math.random() * regular.length)] : this.rand();
+  }
+
+  private endRound() {
+    if (this.roundWin > 0) { this.multLabel.string = ''; this.celebrate(this.roundWin); }
+    else this.multLabel.string = '';
+    this.spinning = false; this.setSpinEnabled(true);
+    if (this.holdActive) return;
+    if (this.freeSpins > 0) this.scheduleOnce(() => this.spin(), this.td(0.9));
+    else if (this.auto && this.balance >= this.bet) this.scheduleOnce(() => this.spin(), this.td(0.9));
+  }
+
+  private flashMult(text: string) {
+    this.multLabel.string = text;
+    const n = this.multLabel.node; n.setScale(0.6, 0.6, 1);
+    tween(n).to(0.3, { scale: new Vec3(1, 1, 1) }, { easing: 'elasticOut' }).start();
+  }
+
+  private playParticle(id: string) {
+    resources.load(`vfx/${id}`, JsonAsset, (err, asset) => {
+      if (err) return;
+      const n = new Node('p'); n.parent = this.node; n.setPosition(0, this.frameCY + this.frameH / 2, 0); n.setSiblingIndex(26);
+      const ps = n.addComponent(ParticleSystem2D);
+      applyParticleConfig(ps, asset.json, this.particleTex as SpriteFrame);
+      this.scheduleOnce(() => ps.stopSystem(), 1.2);
+      this.scheduleOnce(() => n.destroy(), 4);
+    });
+  }
+
+  update(dt: number) {
+    this.t += dt;
+    if (Math.abs(this.displayBalance - this.balance) > 0.5) {
+      this.displayBalance += (this.balance - this.displayBalance) * Math.min(1, dt * 6);
+      this.balanceLabel.string = `${Math.floor(this.displayBalance)}`;
+    }
+    // 待机时每个果冻轻微上下呼吸,画面更"活"
+    if (!this.spinning && this.cells.length) {
+      for (let c = 0; c < COLS; c++) {
+        for (let r = 0; r < ROWS; r++) {
+          const sk = this.cells[c] && this.cells[c][r]; if (!sk) continue;
+          const by = this.symBaseY[c] && this.symBaseY[c][r]; if (by === undefined) continue;
+          const px = sk.node.position.x;
+          sk.node.setPosition(px, by + Math.sin(this.t * 2.2 + c * 0.8 + r * 0.6) * this.cell * FEEDBACK.idleBob, 0);
+        }
+      }
+    }
+  }
+
+  private rand() { return SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]; }
+  private reel(c: number) {
+    const strip = REEL_STRIPS[c % Math.max(1, REEL_STRIPS.length)];
+    return strip && strip.length ? strip : SYMBOLS;
+  }
+  private reelRand(c: number) {
+    const strip = this.reel(c);
+    return strip[Math.floor(Math.random() * strip.length)];
+  }
+
+  private setSpinEnabled(on: boolean) {
+    const btn = this.spinBtn.getComponent(Button); if (btn) btn.interactable = on;
+  }
+
+  private roundRect(g: Graphics, x: number, y: number, w: number, h: number, r: number) {
+    g.moveTo(x + r, y); g.lineTo(x + w - r, y);
+    g.arc(x + w - r, y + r, r, -Math.PI / 2, 0, false);
+    g.lineTo(x + w, y + h - r); g.arc(x + w - r, y + h - r, r, 0, Math.PI / 2, false);
+    g.lineTo(x + r, y + h); g.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI, false);
+    g.lineTo(x, y + r); g.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5, false); g.close();
+  }
+}

+ 60 - 0
test_api.py

@@ -0,0 +1,60 @@
+"""中转接口诊断脚本。
+在能联通中转的机器上运行:
+    python3 test_api.py
+它会:① 列出中转支持的模型;② 用你的模型试生成一张图,打印真实状态码和返回。
+把输出贴给我,我就能精确改代码。
+"""
+
+import json
+import ssl
+import urllib.request
+import urllib.error
+
+import config
+
+# ====== 改成你的配置 ======
+BASE_URL = config.get("ANIM_STUDIO_BASE_URL", "https://x.long.bid/v1")
+API_KEY = config.get("ANIM_STUDIO_API_KEY", "")
+MODEL = config.get("ANIM_STUDIO_IMAGE_MODEL", "gpt-image-2")
+SIZE = "1024x1024"
+# =========================
+
+if not API_KEY:
+    raise SystemExit("缺少 ANIM_STUDIO_API_KEY;请配置环境变量或 local_config.json")
+
+try:
+    import certifi
+    CTX = ssl.create_default_context(cafile=certifi.where())
+except Exception:
+    CTX = ssl.create_default_context()
+
+H = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"}
+
+
+def call(method, path, payload=None, timeout=60):
+    data = json.dumps(payload).encode() if payload is not None else None
+    req = urllib.request.Request(BASE_URL.rstrip("/") + path, data=data, headers=H, method=method)
+    try:
+        with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+            return r.status, r.read().decode("utf-8", "ignore")
+    except urllib.error.HTTPError as e:
+        return e.code, e.read().decode("utf-8", "ignore")
+    except Exception as e:
+        return "ERR", repr(e)
+
+
+print("① 列出模型 GET /models")
+s, b = call("GET", "/models")
+print("   状态:", s)
+print("   返回(前1500):", b[:1500])
+print()
+
+print(f"② 试生成图片 POST /images/generations  (model={MODEL})")
+payload = {"model": MODEL, "prompt": "a cute blue jelly creature, transparent background",
+           "n": 1, "size": SIZE}
+if "gpt-image" in MODEL.lower() and MODEL.lower() != "gpt-image-2":
+    payload["background"] = "transparent"
+    payload["output_format"] = "png"
+s, b = call("POST", "/images/generations", payload)
+print("   状态:", s)
+print("   返回(前1500):", b[:1500])

+ 74 - 0
tween_builder.py

@@ -0,0 +1,74 @@
+"""把 UI 动效预设编译成 Cocos TypeScript(cc.tween)。
+纯本地、无需 API。生成一个 TweenPresets.ts,运行时按 presetId 调用即可。
+"""
+
+import os
+
+PRESETS = {
+    "scale_bounce": """  scale_bounce(node: Node, p: any = {}) {
+    const s = node.scale.clone();
+    return tween(node)
+      .to(0.09, { scale: new Vec3(s.x * 0.9, s.y * 0.9, 1) })
+      .to(0.12, { scale: new Vec3(s.x * 1.05, s.y * 1.05, 1) }, { easing: 'backOut' })
+      .to(0.08, { scale: s });
+  }""",
+    "elastic_in": """  elastic_in(node: Node, p: any = {}) {
+    node.setScale(0, 0, 1);
+    const uo = node.getComponent(UIOpacity); if (uo) uo.opacity = 0;
+    return tween(node)
+      .to(0.4, { scale: new Vec3(1, 1, 1) }, { easing: 'elasticOut' })
+      .call(() => { if (uo) uo.opacity = 255; });
+  }""",
+    "fade_slide_in": """  fade_slide_in(node: Node, p: any = {}) {
+    const dy = p.dy ?? 40;
+    const end = node.position.clone();
+    node.setPosition(end.x, end.y - dy, end.z);
+    const uo = node.getComponent(UIOpacity);
+    if (uo) { uo.opacity = 0; tween(uo).to(0.3, { opacity: 255 }).start(); }
+    return tween(node).to(0.3, { position: end }, { easing: 'cubicOut' });
+  }""",
+    "number_roll": """  number_roll(node: Node, p: any = {}) {
+    const label = node.getComponent(Label)!;
+    const from = p.from ?? 0, to = p.to ?? 0, dur = p.dur ?? 0.8;
+    const o = { v: from };
+    return tween(o).to(dur, { v: to }, {
+      easing: 'cubicOut',
+      onUpdate: () => { label.string = Math.floor(o.v).toString(); },
+    });
+  }""",
+    "pulse": """  pulse(node: Node, p: any = {}) {
+    const s = node.scale.clone();
+    return tween(node).repeatForever(
+      tween(node)
+        .to(0.6, { scale: new Vec3(s.x * 1.06, s.y * 1.06, 1) }, { easing: 'sineInOut' })
+        .to(0.6, { scale: s }, { easing: 'sineInOut' })
+    );
+  }""",
+}
+
+HEADER = """// 自动生成 by anim_studio —— UI 动效预设库
+// 用法: TweenPresets.play('scale_bounce', node, { ... }).start();
+import { tween, Tween, Node, Vec3, UIOpacity, Label } from 'cc';
+
+class _TweenPresets {
+  play(id: string, node: Node, p: any = {}): Tween<any> {
+    const fn = (this as any)[id];
+    if (!fn) { console.warn('[TweenPresets] 未知预设: ' + id); return tween(node); }
+    return fn.call(this, node, p);
+  }
+"""
+
+FOOTER = "}\n\nexport const TweenPresets = new _TweenPresets();\n"
+
+
+def build_tweens(used_presets, out_dir):
+    """生成 TweenPresets.ts。used_presets 为 manifest 用到的预设名列表(去重)。"""
+    # 总是输出全部预设库(多几个不碍事),并校验用到的是否都存在
+    missing = [p for p in used_presets if p not in PRESETS]
+    body = "\n\n".join(PRESETS[k] for k in PRESETS)
+    code = HEADER + "\n" + body + "\n" + FOOTER
+    os.makedirs(out_dir, exist_ok=True)
+    path = os.path.join(out_dir, "TweenPresets.ts")
+    with open(path, "w", encoding="utf-8") as f:
+        f.write(code)
+    return path, missing

+ 647 - 0
web/index.html

@@ -0,0 +1,647 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Anim Studio · 动画资源库</title>
+<style>
+  :root{
+    --bg:#1b1430; --panel:#241a3d; --card:#2c2150; --line:#3d2f63;
+    --accent:#ff7eb6; --accent2:#7ee8ff; --text:#f3eefb; --muted:#a99ccb;
+  }
+  *{box-sizing:border-box}
+  body{margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
+       background:linear-gradient(160deg,#1b1430,#2a1b45);color:var(--text);min-height:100vh}
+  header{padding:18px 24px;border-bottom:1px solid var(--line);display:flex;
+         align-items:center;gap:14px}
+  header h1{font-size:20px;margin:0}
+  header .sub{color:var(--muted);font-size:13px}
+  .wrap{max-width:1180px;margin:0 auto;padding:20px 24px 60px}
+
+  details.gen{background:var(--panel);border:1px solid var(--line);border-radius:14px;
+              padding:0 18px;margin-bottom:22px}
+  details.gen summary{cursor:pointer;padding:16px 0;font-weight:600;font-size:15px;
+              list-style:none}
+  details.gen summary::-webkit-details-marker{display:none}
+  .gen-grid{display:grid;grid-template-columns:320px 1fr;gap:18px;padding:0 0 18px}
+  .workflow-grid{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:12px;padding:0 0 18px}
+  .workflow-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:0 0 18px}
+  .workflow-note{color:var(--muted);font-size:12px;line-height:1.5}
+  .field{margin-bottom:10px}
+  .field label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
+  .field input,.field select,textarea{width:100%;background:#160f29;color:var(--text);
+       border:1px solid var(--line);border-radius:8px;padding:9px 10px;font-size:13px}
+  .field input[type="checkbox"]{width:auto;margin-right:6px;vertical-align:-1px}
+  textarea{font-family:ui-monospace,Menlo,monospace;min-height:300px;resize:vertical;line-height:1.45}
+  button{background:linear-gradient(90deg,var(--accent),#ff9d6e);color:#2a0f23;border:none;
+         border-radius:9px;padding:10px 18px;font-weight:700;cursor:pointer;font-size:14px}
+  button.ghost{background:#2c2150;color:var(--text);border:1px solid var(--line);font-weight:600}
+  button:disabled{opacity:.5;cursor:default}
+  .log{white-space:pre-wrap;background:#0f0a1e;border:1px solid var(--line);border-radius:8px;
+       padding:10px;font-family:ui-monospace,monospace;font-size:12px;color:#bfe;max-height:160px;
+       overflow:auto;margin-top:10px}
+
+  .tabs{display:flex;gap:8px;margin:6px 0 18px}
+  .tabs button{background:#241a3d;color:var(--muted);border:1px solid var(--line)}
+  .tabs button.active{background:var(--accent);color:#2a0f23}
+  .toolbar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
+  .toolbar select{background:#160f29;color:var(--text);border:1px solid var(--line);
+       border-radius:8px;padding:7px 10px;font-size:13px}
+  .count{color:var(--muted);font-size:13px}
+
+  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;
+        display:flex;flex-direction:column;gap:10px}
+  .stage{height:200px;border-radius:10px;background:
+        repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/22px 22px;
+        display:flex;align-items:flex-end;justify-content:center;overflow:hidden;position:relative}
+  .stage img{max-height:88%;max-width:88%;transform-origin:50% 100%;will-change:transform;
+        filter:drop-shadow(0 6px 10px rgba(0,0,0,.35))}
+  .stage canvas{position:absolute;inset:0;width:100%;height:100%}
+  .name{font-weight:700;font-size:15px}
+  .meta{color:var(--muted);font-size:12px;line-height:1.5}
+  .row{display:flex;gap:8px;align-items:center}
+  .row select{flex:1;background:#160f29;color:var(--text);border:1px solid var(--line);
+        border-radius:7px;padding:6px 8px;font-size:12px}
+  .pill{display:inline-block;background:#160f29;border:1px solid var(--line);border-radius:20px;
+        padding:2px 10px;font-size:11px;color:var(--accent2)}
+  .art-img{max-width:100%;max-height:180px;object-fit:contain}
+  .demo-box{width:84px;height:84px;border-radius:14px;
+        background:linear-gradient(135deg,var(--accent),var(--accent2));margin:auto;
+        display:flex;align-items:center;justify-content:center;font-weight:800;color:#241a3d;font-size:22px}
+  .empty{color:var(--muted);text-align:center;padding:50px;border:1px dashed var(--line);border-radius:14px}
+  code{background:#160f29;padding:1px 6px;border-radius:5px;font-size:12px}
+  a{color:var(--accent2)}
+</style>
+</head>
+<body>
+<header>
+  <h1>🍬 Anim Studio</h1>
+  <span class="sub">动画资源库 · 角色 / 特效 / 动效 可视化预览</span>
+</header>
+
+<div class="wrap">
+  <details class="gen" id="workflowPanel" open>
+    <summary>▸ AI 游戏方案(创意 + 风格 + 玩法 → manifest)</summary>
+    <div class="field"><label>创意简报(可填一句话,也可以写完整需求)</label>
+      <textarea id="creativeBrief" style="min-height:92px" placeholder="例:做一个竖屏海盗金币 slot,参考糖果传奇那种明亮可爱的反馈,但主题是热带宝藏。核心钩子是金币 Hold & Win,整体要轻松、亮、适合移动端。"></textarea></div>
+    <div class="workflow-grid">
+      <div class="field"><label>参考图 / 网址链接(一行一个)</label>
+        <textarea id="creativeRefs" style="min-height:92px" placeholder="https://...\n也可以写:本地截图文件名、参考页面链接、竞品链接"></textarea></div>
+      <div class="field"><label>上传参考图 / 截图(最多 4 张)</label>
+        <input id="creativeImageFiles" type="file" accept="image/*" multiple>
+        <div class="workflow-note" id="creativeImageMsg">上传图会先由视觉模型提炼风格,再生成 manifest。</div></div>
+      <div class="field"><label>希望参考的风格点</label>
+        <textarea id="creativeStyleNotes" style="min-height:92px" placeholder="按钮质感、颜色、角色比例、背景气氛、UI 密度等"></textarea></div>
+      <div class="field"><label>必须避免</label>
+        <textarea id="creativeAvoidNotes" style="min-height:92px" placeholder="不要照抄 logo / IP / 角色;不要暗黑;不要复杂文字"></textarea></div>
+      <div>
+        <div class="field"><label>文字模型</label>
+          <input id="textModel" value="gpt-4o-mini"></div>
+        <button class="ghost" id="aiWorkflowBtn">AI 生成完整游戏方案</button>
+        <div class="workflow-note" id="aiWorkflowMsg">先生成统一方案,再微调玩法和图片资源。</div>
+      </div>
+    </div>
+    <div class="log" id="gamePlanView" style="display:none;max-height:260px"></div>
+    <div class="workflow-grid">
+      <div class="field"><label>游戏代号</label>
+        <input id="wfGameId" value="jelly-candy-slot"></div>
+      <div class="field"><label>游戏标题</label>
+        <input id="wfTitle" value="Jelly Candy Slot"></div>
+      <div class="field"><label>主题</label>
+        <select id="wfTheme">
+          <option value="jelly">果冻糖果</option>
+          <option value="fruit">水果乐园</option>
+          <option value="egypt">埃及宝藏</option>
+          <option value="pirate">海盗金币</option>
+          <option value="cyber">霓虹赛博</option>
+        </select></div>
+      <div class="field"><label>基础玩法</label>
+        <select id="wfReelMode">
+          <option value="ways">Ways 5×3</option>
+          <option value="paylines">固定赔线 5×3</option>
+          <option value="megaways">Megaways 6轴</option>
+          <option value="cluster">Cluster Pays 6×5</option>
+        </select></div>
+      <div class="field"><label>波动风格</label>
+        <select id="wfVolatility">
+          <option value="medium">中波动</option>
+          <option value="low">低波动</option>
+          <option value="high">高波动</option>
+        </select></div>
+      <div class="field"><label>目标 RTP(%)</label>
+        <input id="wfTargetRtp" type="number" value="96" min="50" max="99.5" step="0.1"></div>
+      <div class="field"><label><input type="checkbox" id="wfEnableMathModel" checked> 生成赔率和触发概率数学模型</label></div>
+      <div class="field"><label>角色/符号数量</label>
+        <select id="wfCharacterCount">
+          <option>10</option><option>8</option><option>12</option><option>6</option>
+        </select></div>
+      <div class="field"><label>UI 资产范围</label>
+        <select id="wfUiCompleteness">
+          <option value="full">完整</option>
+          <option value="basic">基础</option>
+        </select></div>
+      <div class="field"><label>反馈强度</label>
+        <select id="wfFeedbackIntensity">
+          <option value="standard">标准</option>
+          <option value="quiet">克制</option>
+          <option value="loud">夸张</option>
+        </select></div>
+      <div class="field"><label>初始金币</label>
+        <input id="wfStartingBalance" type="number" value="5000" min="100" step="100"></div>
+      <div class="field"><label>默认下注</label>
+        <input id="wfDefaultBet" type="number" value="50" min="1" step="10"></div>
+      <div class="field"><label>免费旋转次数</label>
+        <input id="wfFreeSpinCount" type="number" value="8" min="3" max="30"></div>
+      <div class="field"><label>最大连锁次数</label>
+        <input id="wfMaxCascades" type="number" value="6" min="1" max="20"></div>
+      <div class="field"><label><input type="checkbox" id="wfCascades" checked> Cascades 连锁下落</label></div>
+      <div class="field"><label><input type="checkbox" id="wfFreeSpins" checked> Scatter Free Spins</label></div>
+      <div class="field"><label><input type="checkbox" id="wfWilds" checked> Wild 符号</label></div>
+      <div class="field"><label><input type="checkbox" id="wfHoldWin"> Hold & Win</label></div>
+      <div class="field"><label><input type="checkbox" id="wfMultipliers"> 连锁倍数</label></div>
+    </div>
+    <div class="workflow-actions">
+      <button id="buildWorkflowBtn">生成玩法配置和 manifest</button>
+      <button class="ghost" id="openGenBtn">打开生成面板</button>
+      <span class="workflow-note" id="workflowMsg">先生成 manifest,再点下方“开始生成”。</span>
+    </div>
+  </details>
+
+  <details class="gen" id="genPanel" open>
+    <summary>▸ 生成图片资源(填 key,点开始)</summary>
+    <div class="gen-grid">
+      <div>
+        <div class="field"><label>接口协议(厂商)</label>
+          <select id="provider"><option>OpenAI 兼容接口</option></select></div>
+        <div class="field"><label>API Key</label>
+          <input id="apiKey" type="password" placeholder="已内置,留空即可;临时换 key 时再填写"></div>
+        <div class="field"><label>Base URL</label>
+          <input id="baseUrl" value="https://x.long.bid/v1"></div>
+        <div class="field"><label>模型名(真正发给 API 的,按你的填)</label>
+          <input id="model" value="gpt-image-2"></div>
+        <div class="field"><label>尺寸</label>
+          <select id="size"><option>1024x1024</option><option>1024x1536</option><option>1536x1024</option></select></div>
+        <div class="field"><label><input type="checkbox" id="removeBg"> 生图后去背景(较慢)</label></div>
+        <button id="startBtn">▶ 开始生成</button>
+        <div class="log" id="log" style="display:none"></div>
+      </div>
+      <div class="field"><label>animation_manifest.json</label>
+        <textarea id="manifest"></textarea></div>
+    </div>
+  </details>
+
+  <div class="toolbar">
+    <span class="meta">资源库(game):</span>
+    <select id="gameSel"></select>
+    <button class="ghost" id="reloadBtn">↻ 刷新</button>
+    <button id="exportBtn">📦 导出 Cocos 整合包</button>
+    <button class="ghost" id="deleteBtn">🗑 删除该资源库</button>
+    <span class="count" id="opMsg"></span>
+  </div>
+
+  <div class="tabs">
+    <button data-tab="chars" class="active">角色库</button>
+    <button data-tab="art">UI 美术</button>
+    <button data-tab="vfx">特效库</button>
+    <button data-tab="ui">动效库</button>
+  </div>
+
+  <div id="view"></div>
+</div>
+
+<script>
+const $ = s => document.querySelector(s);
+let LIB = {characters:[],vfx:[],ui:[]}, ASSET="", TAB="chars";
+const animTargets = []; // {el, anim, start}
+
+// ---------- 拉取默认 manifest & 资源库 ----------
+async function loadManifest(){
+  try{ const t = await (await fetch('/api/manifest')).text(); $('#manifest').value = t; }catch(e){}
+}
+async function loadLibrary(game){
+  const url = '/api/library' + (game?('?game='+encodeURIComponent(game)):'');
+  const lib = await (await fetch(url)).json();
+  LIB = lib; ASSET = lib.assetBase || "";
+  const sel = $('#gameSel'); sel.innerHTML='';
+  (lib.games||[]).forEach(g=>{ const o=document.createElement('option');
+     o.value=g;o.textContent=g; if(g===lib.game)o.selected=true; sel.appendChild(o); });
+  if(!(lib.games||[]).length){ const o=document.createElement('option');o.textContent='(暂无)';sel.appendChild(o); }
+  render();
+}
+
+// ---------- 关键帧采样(与后端 spine 数据一致)----------
+const lerp=(a,b,t)=>a+(b-a)*t;
+function sample(keys,t,field){
+  if(!keys||!keys.length) return field==='value'?0:1;
+  if(t<=keys[0].time) return keys[0][field];
+  for(let i=0;i<keys.length-1;i++){
+    if(t>=keys[i].time && t<=keys[i+1].time){
+      const f=(t-keys[i].time)/((keys[i+1].time-keys[i].time)||1);
+      return lerp(keys[i][field],keys[i+1][field],f);
+    }
+  }
+  return keys[keys.length-1][field];
+}
+function tick(now){
+  for(const t of animTargets){
+    if(!t.el.isConnected) continue;
+    const a=t.anim; if(!a){continue;}
+    const dur=a.duration||1; const time=((now-t.start)/1000)%dur;
+    const sx=sample(a.scale,time,'x'), sy=sample(a.scale,time,'y');
+    const rot=sample(a.rotate,time,'value');
+    t.el.style.transform=`scaleX(${sx}) scaleY(${sy}) rotate(${(-rot).toFixed(2)}deg)`;
+  }
+  requestAnimationFrame(tick);
+}
+requestAnimationFrame(tick);
+
+// ---------- 渲染 ----------
+function render(){
+  const v=$('#view'); v.innerHTML=''; animTargets.length=0;
+  if(TAB==='chars') renderChars(v);
+  if(TAB==='art') renderArt(v);
+  if(TAB==='vfx') renderVfx(v);
+  if(TAB==='ui') renderUi(v);
+}
+
+function renderChars(v){
+  const list=LIB.characters||[];
+  if(!list.length){ v.innerHTML='<div class="empty">还没有角色。填 key 在生成面板点「开始」即可生成。</div>'; return; }
+  const grid=document.createElement('div'); grid.className='grid';
+  list.forEach(c=>{
+    const card=document.createElement('div'); card.className='card';
+    const anims=Object.keys(c.animations||{});
+    card.innerHTML=`
+      <div class="stage"><img src="${ASSET+c.png}" alt="${c.id}"></div>
+      <div class="name">${c.id}</div>
+      <div class="row">
+        <select>${anims.map(a=>`<option>${a}</option>`).join('')}</select>
+      </div>
+      <div class="meta">${c.w}×${c.h}px · ${(c.files||[]).length} 个文件<br>
+        <span class="pill">spine</span> 动画: ${anims.join(', ')||'idle'}</div>`;
+    const img=card.querySelector('img'), selA=card.querySelector('select');
+    const tgt={el:img, anim:c.animations[anims[0]], start:performance.now()};
+    animTargets.push(tgt);
+    selA.onchange=()=>{ tgt.anim=c.animations[selA.value]; tgt.start=performance.now(); };
+    grid.appendChild(card);
+  });
+  v.appendChild(grid);
+}
+
+function renderArt(v){
+  const list=LIB.ui_art||[];
+  if(!list.length){ v.innerHTML='<div class="empty">还没有 UI 美术。用工作流生成 manifest 后点「开始生成」。</div>'; return; }
+  const grid=document.createElement('div'); grid.className='grid';
+  list.forEach(a=>{
+    const card=document.createElement('div'); card.className='card';
+    card.innerHTML=`
+      <div class="stage"><img class="art-img" src="${ASSET+a.file}" alt="${a.id}"></div>
+      <div class="name">${a.id}</div>
+      <div class="meta">${a.w}×${a.h}px · ${a.transparent?'透明素材':'整图背景'}<br>
+        <span class="pill">ui_art</span> ${a.file}</div>`;
+    grid.appendChild(card);
+  });
+  v.appendChild(grid);
+}
+
+function renderVfx(v){
+  const list=LIB.vfx||[];
+  if(!list.length){ v.innerHTML='<div class="empty">还没有特效。</div>'; return; }
+  const grid=document.createElement('div'); grid.className='grid';
+  list.forEach(x=>{
+    const card=document.createElement('div'); card.className='card';
+    card.innerHTML=`
+      <div class="stage"><canvas></canvas></div>
+      <div class="name">${x.id}</div>
+      <div class="meta"><span class="pill">particle</span> 模板: ${x.template} ·
+        发射率 ${x.config.emissionRate}/s · 寿命 ${x.config.life}s</div>`;
+    grid.appendChild(card);
+    startParticle(card.querySelector('canvas'), x.config);
+  });
+  v.appendChild(grid);
+}
+
+function startParticle(canvas, cfg){
+  const dpr=window.devicePixelRatio||1;
+  function resize(){ canvas.width=canvas.clientWidth*dpr; canvas.height=canvas.clientHeight*dpr; }
+  setTimeout(resize,0); window.addEventListener('resize',resize);
+  const ctx=canvas.getContext('2d');
+  const ps=[]; let acc=0, last=performance.now();
+  const sc=(cfg.startColor||[255,255,255,255]), ec=(cfg.endColor||[255,255,255,0]);
+  const rand=(v)=>(Math.random()*2-1)*(v||0);
+  function spawn(){
+    const W=canvas.width,H=canvas.height;
+    const cx=W/2+rand(cfg.posVarX)*dpr, cy=H*0.5+rand(cfg.posVarY)*dpr;
+    const ang=((cfg.angle||90)+rand(cfg.angleVar))*Math.PI/180;
+    const sp=((cfg.speed||100)+rand(cfg.speedVar))*dpr*0.5;
+    ps.push({x:cx,y:cy,vx:Math.cos(ang)*sp,vy:-Math.sin(ang)*sp,
+      life:(cfg.life||1)+rand(cfg.lifeVar),age:0,
+      ss:(cfg.startSize||20),es:(cfg.endSize!=null?cfg.endSize:cfg.startSize||20)});
+  }
+  function loop(now){
+    if(!canvas.isConnected) return;
+    const dt=Math.min(0.05,(now-last)/1000); last=now;
+    const W=canvas.width,H=canvas.height;
+    acc+=(cfg.emissionRate||60)*dt;
+    while(acc>=1 && ps.length<260){ acc-=1; spawn(); }
+    ctx.clearRect(0,0,W,H); ctx.globalCompositeOperation='lighter';
+    for(let i=ps.length-1;i>=0;i--){
+      const p=ps[i]; p.age+=dt;
+      if(p.age>=p.life){ ps.splice(i,1); continue; }
+      p.vy+=(-(cfg.gravityY||0))*dpr*dt; p.vx+=(cfg.gravityX||0)*dpr*dt;
+      p.x+=p.vx*dt; p.y+=p.vy*dt;
+      const f=p.age/p.life;
+      const r=Math.max(0.5,(p.ss+(p.es-p.ss)*f))*dpr/2;
+      const cr=Math.round(lerp(sc[0],ec[0],f)),cg=Math.round(lerp(sc[1],ec[1],f)),cb=Math.round(lerp(sc[2],ec[2],f));
+      const a=lerp((sc[3]??255),(ec[3]??0),f)/255;
+      ctx.beginPath();ctx.fillStyle=`rgba(${cr},${cg},${cb},${a})`;
+      ctx.arc(p.x,p.y,r,0,7);ctx.fill();
+    }
+    requestAnimationFrame(loop);
+  }
+  requestAnimationFrame(loop);
+}
+
+// ---------- UI tween 预览(JS 复刻,仅用于看效果)----------
+const EASE={
+  cubicOut:t=>1-Math.pow(1-t,3),
+  backOut:t=>{const c=1.7;return 1+(c+1)*Math.pow(t-1,3)+c*Math.pow(t-1,2);},
+  elasticOut:t=>t===0?0:t===1?1:Math.pow(2,-10*t)*Math.sin((t*10-0.75)*(2*Math.PI/3))+1,
+  sineInOut:t=>-(Math.cos(Math.PI*t)-1)/2,
+  linear:t=>t,
+};
+function animate(dur,ease,fn){
+  const s=performance.now();
+  function step(now){const t=Math.min(1,(now-s)/(dur*1000));fn(EASE[ease](t));if(t<1)requestAnimationFrame(step);}
+  requestAnimationFrame(step);
+}
+const TWEEN_DEMO={
+  scale_bounce:(el)=>{animate(0.09,'linear',t=>el.style.transform=`scale(${1-0.1*t})`);
+    setTimeout(()=>animate(0.12,'backOut',t=>el.style.transform=`scale(${0.9+0.15*t})`),90);
+    setTimeout(()=>animate(0.08,'linear',t=>el.style.transform=`scale(${1.05-0.05*t})`),210);},
+  elastic_in:(el)=>{el.style.opacity=1;animate(0.45,'elasticOut',t=>el.style.transform=`scale(${t})`);},
+  fade_slide_in:(el)=>{animate(0.3,'cubicOut',t=>{el.style.opacity=t;el.style.transform=`translateY(${40*(1-t)}px)`;});},
+  number_roll:(el)=>{el.dataset.role='num';animate(0.8,'cubicOut',t=>el.textContent=Math.floor(8888*t));},
+  pulse:(el)=>{let on=true;el._pi&&clearInterval(el._pi);
+    el._pi=setInterval(()=>{animate(0.6,'sineInOut',t=>el.style.transform=`scale(${on?1+0.06*t:1.06-0.06*t})`);on=!on;},620);},
+};
+function renderUi(v){
+  const list=LIB.ui||[];
+  if(!list.length){ v.innerHTML='<div class="empty">还没有 UI 动效。已生成的会编译进 <code>ui/TweenPresets.ts</code>。</div>'; return; }
+  const grid=document.createElement('div'); grid.className='grid';
+  list.forEach(u=>{
+    const card=document.createElement('div'); card.className='card';
+    card.innerHTML=`<div class="stage"><div class="demo-box">${u.preset==='number_roll'?'0':'UI'}</div></div>
+      <div class="name">${u.id}</div>
+      <div class="meta"><span class="pill">tween</span> 预设: ${u.preset}</div>
+      <button class="ghost">▶ 播放</button>`;
+    const box=card.querySelector('.demo-box');
+    card.querySelector('button').onclick=()=>{
+      box.style.opacity=1;box.style.transform='';
+      (TWEEN_DEMO[u.preset]||(()=>{}))(box);
+    };
+    grid.appendChild(card);
+  });
+  v.appendChild(grid);
+}
+
+// ---------- 事件 ----------
+document.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{
+  document.querySelectorAll('.tabs button').forEach(x=>x.classList.remove('active'));
+  b.classList.add('active'); TAB=b.dataset.tab; render();
+});
+$('#gameSel').onchange=e=>loadLibrary(e.target.value);
+$('#reloadBtn').onclick=()=>loadLibrary($('#gameSel').value);
+
+function opMsg(t,ok=true){ const m=$('#opMsg'); m.textContent=t; m.style.color=ok?'#7ee8ff':'#ff9d9d'; }
+function workflowMsg(t,ok=true){ const m=$('#workflowMsg'); m.textContent=t; m.style.color=ok?'#a99ccb':'#ff9d9d'; }
+
+function renderGamePlan(plan, source){
+  const box=$('#gamePlanView');
+  if(!plan){ box.style.display='none'; box.textContent=''; return; }
+  const gd=plan.gameDesign||{}, creative=plan.creative||{}, art=gd.artDirection||{};
+  const lines=[
+    `AI 游戏方案 · ${source||plan.source||''}`,
+    `标题:${gd.title||''}`,
+    `核心钩子:${gd.coreHook||''}`,
+    `玩法:${gd.reelExperience||''} / ${gd.volatility||''}`,
+    `差异点:${(gd.differentiators||[]).join('、')||'未生成'}`,
+    `美术主题:${art.theme||''}`,
+    `美术风格:${art.style||''}`,
+    `参考:${(creative.references||[]).join(',')||'无'};上传图 ${creative.uploadedReferenceImages||0} 张`,
+    `视觉分析:${creative.visionStyleAnalysis&&Object.keys(creative.visionStyleAnalysis).length?JSON.stringify(creative.visionStyleAnalysis,null,2):(creative.visionStyleAnalysisError||'无')}`
+  ];
+  box.textContent=lines.join('\n');
+  box.style.display='block';
+}
+
+function workflowPayload(){
+  const features=[];
+  if($('#wfCascades').checked) features.push('cascades');
+  if($('#wfFreeSpins').checked) features.push('free_spins');
+  if($('#wfWilds').checked) features.push('wilds');
+  if($('#wfHoldWin').checked) features.push('hold_win');
+  if($('#wfMultipliers').checked) features.push('multipliers');
+  return {
+    gameId: $('#wfGameId').value,
+    title: $('#wfTitle').value,
+    theme: $('#wfTheme').value,
+    reelMode: $('#wfReelMode').value,
+    volatility: $('#wfVolatility').value,
+    targetRtp: $('#wfTargetRtp').value,
+    enableMathModel: $('#wfEnableMathModel').checked,
+    characterCount: $('#wfCharacterCount').value,
+    uiCompleteness: $('#wfUiCompleteness').value,
+    feedbackIntensity: $('#wfFeedbackIntensity').value,
+    startingBalance: $('#wfStartingBalance').value,
+    defaultBet: $('#wfDefaultBet').value,
+    freeSpinCount: $('#wfFreeSpinCount').value,
+    maxCascades: $('#wfMaxCascades').value,
+    creative: {
+      brief: $('#creativeBrief').value,
+      references: $('#creativeRefs').value.split('\n').map(x=>x.trim()).filter(Boolean),
+      uploadedReferenceImages: ($('#creativeImageFiles').files||[]).length,
+      styleNotes: $('#creativeStyleNotes').value,
+      avoidNotes: $('#creativeAvoidNotes').value
+    },
+    gameDesign: {
+      title: $('#wfTitle').value,
+      artDirection: { theme: $('#wfTheme').value, styleNotes: $('#creativeStyleNotes').value },
+      reelExperience: $('#wfReelMode').value,
+      volatility: $('#wfVolatility').value
+    },
+    features
+  };
+}
+
+$('#buildWorkflowBtn').onclick=async()=>{
+  const btn=$('#buildWorkflowBtn'); btn.disabled=true; workflowMsg('正在生成玩法配置和 manifest…');
+  try{
+    const r=await fetch('/api/slot-workflow',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify(workflowPayload())});
+    const d=await r.json();
+    if(!d.ok){ workflowMsg('生成失败: '+(d.error||'未知错误'),false); }
+    else{
+      $('#manifest').value=JSON.stringify(d.manifest,null,2);
+      const cfg=d.slot_config||d.manifest.slot_config||{};
+      const reels=cfg.reels?`${cfg.reels.columns}×${cfg.reels.rows}`:'';
+      const minMatch=cfg.winRules?cfg.winRules.minMatch:'';
+      const sim=cfg.mathModel&&cfg.mathModel.simulation?cfg.mathModel.simulation:{};
+      if(cfg.mathModel&&cfg.mathModel.status==='disabled_by_user'){
+        workflowMsg(`已生成 ${d.manifest.game}:${reels},数学模型未生成,最小中奖 ${minMatch} 连。`);
+      }else{
+        workflowMsg(`已生成 ${d.manifest.game}:${reels},RTP ${((sim.estimatedRtp||0)*100).toFixed(2)}%,命中率 ${((sim.hitFrequency||0)*100).toFixed(2)}%,最小中奖 ${minMatch} 连。`);
+      }
+      $('#genPanel').open=true;
+    }
+  }catch(e){ workflowMsg('请求失败: '+e,false); }
+  btn.disabled=false;
+};
+
+async function readReferenceImages(){
+  const input=$('#creativeImageFiles');
+  const files=Array.from(input.files||[]).slice(0,4);
+  if(!files.length) return [];
+  $('#creativeImageMsg').textContent=`已选择 ${files.length} 张,准备上传给视觉模型分析…`;
+  const readers=files.map(file=>new Promise((resolve,reject)=>{
+    if(file.size>6*1024*1024){ reject(new Error(file.name+' 超过 6MB')); return; }
+    const r=new FileReader();
+    r.onload=()=>resolve(r.result);
+    r.onerror=()=>reject(r.error||new Error('读取失败'));
+    r.readAsDataURL(file);
+  }));
+  return Promise.all(readers);
+}
+
+$('#aiWorkflowBtn').onclick=async()=>{
+  const btn=$('#aiWorkflowBtn'); btn.disabled=true; const msg=$('#aiWorkflowMsg');
+  msg.textContent='AI 正在理解创意和参考链接…'; msg.style.color='#a99ccb';
+  try{
+    const referenceImages=await readReferenceImages();
+    if(referenceImages.length) msg.textContent='视觉模型正在分析上传参考图…';
+    const r=await fetch('/api/creative-manifest',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({
+        api_key:$('#apiKey').value,
+        base_url:$('#baseUrl').value,
+        text_model:$('#textModel').value,
+        gameId:$('#wfGameId').value,
+        title:$('#wfTitle').value,
+        brief:$('#creativeBrief').value,
+        references:$('#creativeRefs').value,
+        reference_images:referenceImages,
+        styleNotes:$('#creativeStyleNotes').value,
+        avoidNotes:$('#creativeAvoidNotes').value,
+        targetRtp:$('#wfTargetRtp').value,
+        enableMathModel:$('#wfEnableMathModel').checked
+      })});
+    const d=await r.json();
+    if(!d.ok){ msg.textContent='AI 生成失败: '+(d.error||'未知错误'); msg.style.color='#ff9d9d'; }
+    else{
+      $('#manifest').value=JSON.stringify(d.manifest,null,2);
+      const req=d.creative_request||{};
+      $('#wfGameId').value=req.gameId||$('#wfGameId').value;
+      $('#wfTitle').value=req.title||$('#wfTitle').value;
+      if(req.theme) $('#wfTheme').value=req.theme;
+      if(req.reelMode) $('#wfReelMode').value=req.reelMode;
+      if(req.volatility) $('#wfVolatility').value=req.volatility;
+      if(req.characterCount) $('#wfCharacterCount').value=String(req.characterCount);
+      if(req.uiCompleteness) $('#wfUiCompleteness').value=req.uiCompleteness;
+      if(req.feedbackIntensity) $('#wfFeedbackIntensity').value=req.feedbackIntensity;
+      const fs=new Set(req.features||[]);
+      $('#wfCascades').checked=fs.has('cascades');
+      $('#wfFreeSpins').checked=fs.has('free_spins');
+      $('#wfWilds').checked=fs.has('wilds');
+      $('#wfHoldWin').checked=fs.has('hold_win');
+      $('#wfMultipliers').checked=fs.has('multipliers');
+      renderGamePlan(d.game_plan, d.source);
+      msg.textContent=`已生成完整游戏方案和 manifest(来源:${d.source}),可微调后开始生成图片资源。`;
+      $('#genPanel').open=true;
+    }
+  }catch(e){ msg.textContent='请求失败: '+e; msg.style.color='#ff9d9d'; }
+  btn.disabled=false;
+};
+
+$('#openGenBtn').onclick=()=>{ $('#genPanel').open=true; $('#genPanel').scrollIntoView({behavior:'smooth',block:'start'}); };
+
+$('#exportBtn').onclick=async()=>{
+  const game=$('#gameSel').value;
+  if(!game||game==='(暂无)'){ opMsg('没有可导出的资源库',false); return; }
+  const btn=$('#exportBtn'); btn.disabled=true; opMsg('打包中…');
+  try{
+    const r=await fetch('/api/export',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({game})});
+    const d=await r.json();
+    if(d.ok) opMsg('✅ 已导出到 '+d.pack);
+    else opMsg('❌ '+(d.error||'导出失败'),false);
+  }catch(e){ opMsg('请求失败: '+e,false); }
+  btn.disabled=false;
+};
+
+$('#deleteBtn').onclick=async()=>{
+  const game=$('#gameSel').value;
+  if(!game||game==='(暂无)'){ opMsg('没有可删除的资源库',false); return; }
+  if(!confirm(`确定删除资源库「${game}」?此操作会从磁盘删掉 out/${game} 整个文件夹,不可恢复。`)) return;
+  const btn=$('#deleteBtn'); btn.disabled=true; opMsg('删除中…');
+  try{
+    const r=await fetch('/api/delete',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({game})});
+    const d=await r.json();
+    if(d.ok){ opMsg('✅ 已删除 '+d.deleted); await loadLibrary(); }
+    else opMsg('❌ '+(d.error||'删除失败'),false);
+  }catch(e){ opMsg('请求失败: '+e,false); }
+  btn.disabled=false;
+};
+$('#startBtn').onclick=async()=>{
+  const btn=$('#startBtn'); const log=$('#log');
+  btn.disabled=true; log.style.display='block'; log.textContent='任务创建中…';
+  try{
+    const r=await fetch('/api/generate',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({
+        provider:$('#provider').value, api_key:$('#apiKey').value,
+        base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value,
+        remove_bg:$('#removeBg').checked,
+        manifest:$('#manifest').value, async:true })});
+    const d=await r.json();
+    if(!d.ok || !d.jobId){
+      log.textContent=(d.logs||[]).join('\n')+(d.error?('\n❌ '+d.error):'');
+      btn.disabled=false;
+      return;
+    }
+    await pollJob(d.jobId, log, btn);
+  }catch(e){ log.textContent='请求失败: '+e; }
+};
+
+async function pollJob(jobId, log, btn){
+  let lastText='';
+  while(true){
+    let d;
+    try{
+      d=await (await fetch('/api/job?id='+encodeURIComponent(jobId))).json();
+    }catch(e){
+      log.textContent=lastText+'\n轮询失败: '+e;
+      btn.disabled=false;
+      return;
+    }
+    const lines=d.logs||[];
+    const head=`任务 ${jobId.slice(0,8)} · ${d.status||'running'} · ${(d.game||'')}`;
+    lastText=head+'\n'+lines.join('\n')+(d.error?('\n❌ '+d.error):'');
+    log.textContent=lastText;
+    log.scrollTop=log.scrollHeight;
+    if(d.status==='done'){
+      await loadLibrary(d.game);
+      btn.disabled=false;
+      return;
+    }
+    if(d.status==='error'){
+      btn.disabled=false;
+      return;
+    }
+    await new Promise(r=>setTimeout(r,1200));
+  }
+}
+
+loadManifest(); loadLibrary();
+</script>
+</body>
+</html>

+ 434 - 0
游戏定义到自动生图工作流.md

@@ -0,0 +1,434 @@
+# 游戏定义到自动生图工作流
+
+目标:把“一个游戏想法 + 参考图”稳定转换成 `animation_manifest.json`,再自动生成素材、预览资源库、导出 Cocos 整合包。
+
+当前系统已经完成后半段:
+
+```text
+animation_manifest.json
+  -> 角色 / UI 美术 / 粒子 / Tween 自动生成
+  -> 网页资源库预览
+  -> Cocos 整合包
+```
+
+还缺的前半段是“游戏定义层”:
+
+```text
+规范需求 + 参考图
+  -> 文字模型生成游戏细则
+  -> 文字模型生成 manifest
+  -> 自动生图 / 导出
+```
+
+## 1. 总流程
+
+```text
+01 输入需求包
+   game_request.md + reference_images/
+
+02 文字模型做策划细化
+   输出 game_design_spec.json
+
+03 文字模型做游戏反馈定义
+   输出 feedback_spec.json
+
+04 文字模型做资源拆解
+   输出 asset_plan.json
+
+05 文字模型生成 animation_manifest.json
+   必须通过 schema 校验
+
+06 Anim Studio 生成素材
+   characters / ui_art / vfx / ui
+
+07 网页 QA
+   预览、删错、补生、重生
+
+08 导出 Cocos 包
+   out/<game>/cocos-pack/
+```
+
+这个流程里,文字模型不直接生图,只负责把需求变成稳定、完整、可复用的 manifest。图像模型只按 manifest 执行。
+
+如果游戏品类是 slot / 老虎เกมสล็อต,建议在 `game_design_spec.json` 后增加一层 `slot_game_config.json`,让用户通过表单点选转轮模式、特殊符号、bonus、反馈强度和资产范围。完整模板见:[老虎机玩法配置工作流.md](老虎机玩法配置工作流.md) 与 [slot_game_config_template.json](slot_game_config_template.json)。
+
+## 2. 输入格式:game_request.md
+
+每个新游戏先填一份规范需求,避免只靠一句话让模型乱发挥。
+
+```md
+# 游戏需求
+
+## 基本信息
+- 游戏代号:jelly-candy-slot
+- 品类:竖屏 H5 slot
+- 引擎:Cocos Creator 3.8.x
+- 目标画幅:竖屏,参考 798x1724
+- 核心体验:轻松、甜品、果冻弹性、中奖反馈强
+
+## 玩法
+- 转轮:5 列 x 3 行
+- 操作:Spin、下注加减、自动旋转
+- 奖励:普通中奖、Big Win、Free Spin
+- 核心循环:下注 -> Spin -> 停轮 -> 结算 -> 中奖反馈
+
+## 美术方向
+- 主题:果冻糖果世界
+- 材质:3D 渲染感、半透明果冻、厚高光
+- 色彩:高饱和糖果色,背景明亮,UI 蓝紫为主
+- 禁止:写实人物、灰暗、恐怖、复杂文字
+
+## 必要素材
+- 吉祥物 / 符号:8 个果冻角色 + 金币 + lucky seven
+- UI 美术:主背景、logo、转轮框、spin 按钮、圆形小按钮、HUD 面板
+- 特效:金币雨、中奖爆光、大赢光晕、彩纸
+- 动效:按钮按压、弹窗入场、面板滑入、数字滚动、图标呼吸
+
+## 参考图
+- reference_images/ref_01.png:整体色彩与糖果世界
+- reference_images/ref_02.png:按钮质感
+- reference_images/ref_03.png:转轮布局
+
+## 约束
+- 所有资产必须原创,不能复刻参考图里的具体角色或 logo。
+- 角色图尽量居中、完整、干净边缘。
+- 透明素材不要画棋盘格背景。
+```
+
+## 3. 中间产物一:game_design_spec.json
+
+文字模型第一步输出“游戏定义”,用于统一后续判断。
+
+```json
+{
+  "game": "jelly-candy-slot",
+  "genre": "vertical_h5_slot",
+  "engine": "cocos_creator_3_8",
+  "viewport": { "orientation": "portrait", "width": 798, "height": 1724 },
+  "core_loop": ["bet", "spin", "reel_stop", "payout", "win_feedback"],
+  "screen_tree": {
+    "main": ["background", "logo", "reel_area", "mascot_or_symbols", "hud", "spin_button"],
+    "overlays": ["win_popup", "free_spin_popup", "settings", "paytable"]
+  },
+  "art_direction": {
+    "theme": "jelly candy land",
+    "materials": ["translucent jelly", "glossy candy", "soft highlights"],
+    "palette": ["pink", "blue", "purple", "gold", "lemon yellow"],
+    "negative": ["photorealistic human", "dark horror", "busy text", "checkerboard background"]
+  }
+}
+```
+
+## 4. 游戏反馈定义:feedback_spec.json
+
+这一步专门约束“玩家操作后游戏如何回应”。它不直接生图,但会决定需要哪些动效、粒子、角色动画、弹窗素材和声音占位。
+
+没有这一层,模型通常只会生成静态素材,Cocos 包也只像“素材展厅”,不像一个有手感的游戏。
+
+```json
+{
+  "feedback_principles": {
+    "overall_feel": "juicy, elastic, bright, fast response",
+    "timing": "tap feedback within 80ms, win reveal within 600ms after reels stop",
+    "intensity_levels": ["micro", "normal_win", "big_win", "bonus"],
+    "avoid": ["long blocking animations", "dark flashes", "unclear win state"]
+  },
+  "player_actions": [
+    {
+      "id": "tap_spin",
+      "trigger": "player_taps_spin_button",
+      "visual": ["spin button compresses", "button glow pulses once"],
+      "ui_animation": ["spin_btn_press"],
+      "vfx": [],
+      "sound_cue": "soft_pop",
+      "duration_ms": 180
+    },
+    {
+      "id": "change_bet",
+      "trigger": "player_taps_bet_plus_or_minus",
+      "visual": ["bet value bumps", "small pill highlight"],
+      "ui_animation": ["balance_roll", "pulse"],
+      "vfx": [],
+      "sound_cue": "tick",
+      "duration_ms": 220
+    }
+  ],
+  "game_events": [
+    {
+      "id": "reels_start",
+      "trigger": "spin_started",
+      "visual": ["reel frame glow dims", "symbols blur vertically"],
+      "ui_animation": ["panel_slide_in"],
+      "vfx": [],
+      "duration_ms": 300
+    },
+    {
+      "id": "small_win",
+      "trigger": "payout_greater_than_bet",
+      "visual": ["winning symbols bounce", "win amount rolls up"],
+      "characters": [{ "role": "winning_symbols", "animation": "win" }],
+      "ui_animation": ["scale_bounce", "number_roll"],
+      "vfx": ["win_burst"],
+      "sound_cue": "win_small",
+      "duration_ms": 900
+    },
+    {
+      "id": "big_win",
+      "trigger": "payout_at_least_10x_bet",
+      "visual": ["big win popup enters", "mascot plays win", "coins rain over screen"],
+      "characters": [{ "role": "mascot_or_symbols", "animation": "win" }],
+      "ui_animation": ["reward_popup_in", "win_icon_pulse", "number_roll"],
+      "vfx": ["coin_rain", "bigwin_glow", "confetti_pop"],
+      "sound_cue": "win_big",
+      "duration_ms": 2400,
+      "blocks_input": true
+    },
+    {
+      "id": "bonus_enter",
+      "trigger": "free_spin_unlocked",
+      "visual": ["screen bright flash", "free spin badge pops in", "background glow increases"],
+      "ui_animation": ["elastic_in", "pulse"],
+      "vfx": ["confetti_pop", "bigwin_glow"],
+      "sound_cue": "bonus_unlock",
+      "duration_ms": 1800,
+      "blocks_input": true
+    }
+  ],
+  "state_feedback": [
+    {
+      "id": "insufficient_balance",
+      "trigger": "spin_blocked_by_low_balance",
+      "visual": ["balance pill shakes", "spin button disabled tint"],
+      "ui_animation": ["scale_bounce"],
+      "vfx": [],
+      "sound_cue": "error_soft",
+      "duration_ms": 320
+    },
+    {
+      "id": "auto_spin_active",
+      "trigger": "auto_spin_enabled",
+      "visual": ["auto button glows", "spin button shows loop state"],
+      "ui_animation": ["pulse"],
+      "vfx": [],
+      "duration_ms": -1
+    }
+  ]
+}
+```
+
+每个反馈事件建议统一使用这些字段:
+
+| 字段 | 用途 |
+|---|---|
+| `id` | 反馈事件唯一 id |
+| `trigger` | 由玩法逻辑触发的条件 |
+| `priority` | 多个反馈同时出现时谁优先 |
+| `intensity` | `micro` / `normal_win` / `big_win` / `bonus` |
+| `visual` | 人能读懂的视觉描述 |
+| `characters` | 哪些角色或符号播放哪段 Spine 动画 |
+| `ui_animation` | 使用哪些 tween preset 或 ui 动效 id |
+| `vfx` | 使用哪些粒子特效 id |
+| `sound_cue` | 声音占位名,后续可接音频管线 |
+| `haptics` | 震动占位名,H5 可忽略,App 可用 |
+| `duration_ms` | 总时长,`-1` 表示持续状态 |
+| `can_overlap` | 是否允许和其他反馈叠加 |
+| `blocks_input` | 播放期间是否阻塞点击 |
+
+反馈强度建议固定为四档:
+
+| 强度 | 用途 | 时长 | 典型表现 |
+|---|---|---:|---|
+| `micro` | 点击、切换、普通状态变化 | 80-300ms | 按钮压缩、数值轻弹、小音效 |
+| `normal_win` | 小奖、普通命中 | 600-1200ms | 符号 bounce、金额滚动、小爆光 |
+| `big_win` | 大奖、高倍赔付 | 1800-3500ms | 弹窗、金币雨、角色 win、强音效 |
+| `bonus` | 免费旋转、特殊模式 | 1500-3000ms | 转场、徽章、彩纸、背景变亮 |
+
+`feedback_spec.json` 会反向约束 manifest:
+
+- 如果反馈里用了 `coin_rain`,manifest 的 `vfx` 必须有 `coin_rain`。
+- 如果反馈里用了 `reward_popup_in`,manifest 的 `ui` 必须有对应动效。
+- 如果反馈里用了角色 `win`,对应 `characters[].animations` 必须包含 `win`。
+- 如果反馈里出现 `big_win_popup` 这类 UI 表现,`ui_art` 应该补对应弹窗或徽章素材。
+
+## 5. 中间产物二:asset_plan.json
+
+第二步把游戏定义拆成资产清单。它不是最终 manifest,而是给人检查的“策划表”。
+
+```json
+{
+  "characters": [
+    { "id": "jelly_blue", "role": "low_symbol", "description": "blue blueberry jelly mascot", "animations": ["idle", "win"] },
+    { "id": "symbol_seven", "role": "high_symbol", "description": "lucky seven symbol", "animations": ["idle", "win"] }
+  ],
+  "ui_art": [
+    { "id": "bg_main", "role": "main_scene_background", "transparent": false, "size": "1024x1536" },
+    { "id": "btn_spin", "role": "primary_action_button", "transparent": true, "size": "1024x1024" }
+  ],
+  "vfx": [
+    { "id": "coin_rain", "template": "rain", "trigger": "big_win" }
+  ],
+  "ui": [
+    { "id": "spin_btn_press", "preset": "scale_bounce", "target": "spin_button" }
+  ]
+}
+```
+
+## 6. 最终产物:animation_manifest.json
+
+第三步才生成当前系统能直接执行的 manifest。
+
+原则:
+
+- `game` 必须短横线命名,作为输出目录名。
+- `style` 是全局风格,不要塞具体角色。
+- `characters[].prompt` 只描述单个角色或符号。
+- `ui_art[].prompt` 只描述单个 UI 素材。
+- `vfx` 优先用已有模板:`rain`、`burst`、`glow`、`confetti`。
+- `ui` 优先用已有预设:`scale_bounce`、`elastic_in`、`fade_slide_in`、`number_roll`、`pulse`。
+- 禁止把玩法规则、长篇说明、多个素材混在一个 prompt 里。
+
+## 7. 文字模型 Prompt 模板
+
+### 7.1 生成游戏细则
+
+```text
+你是 Cocos 3.8 竖屏 H5 游戏策划和技术美术。
+请根据用户的 game_request.md 和参考图说明,输出 game_design_spec.json。
+
+要求:
+1. 只输出 JSON,不要 Markdown。
+2. 资产必须原创,只学习参考图的品类、布局、材质和色彩,不复制具体角色、logo、文字。
+3. 明确 genre、viewport、core_loop、screen_tree、art_direction、negative。
+4. 不要生成 animation_manifest。
+```
+
+### 7.2 生成游戏反馈
+
+```text
+你是 mobile game feel designer,擅长 slot / casual game 的反馈设计。
+请根据 game_design_spec.json 输出 feedback_spec.json。
+
+要求:
+1. 只输出 JSON。
+2. 必须包含 feedback_principles、player_actions、game_events、state_feedback。
+3. 至少定义 tap_spin、reels_start、small_win、big_win、bonus_enter、insufficient_balance。
+4. 每个反馈项必须包含 id、trigger、visual、duration_ms。
+5. 优先复用已有动画预设:scale_bounce、elastic_in、fade_slide_in、number_roll、pulse。
+6. 优先复用当前可生成的 vfx:coin_rain、win_burst、bigwin_glow、confetti_pop。
+7. 不要创造无法落地的复杂镜头语言,保持 Cocos 2D 可实现。
+```
+
+### 7.3 生成资产拆解
+
+```text
+你是游戏资产制片。
+请根据 game_design_spec.json 和 feedback_spec.json 输出 asset_plan.json。
+
+要求:
+1. 只输出 JSON。
+2. characters 控制在 8-12 个,包含普通符号、高价值符号或吉祥物。
+3. ui_art 包含主背景、logo、转轮框、主按钮、次级按钮、HUD 面板。
+4. vfx 只能使用 rain / burst / glow / confetti / trail 这些模板。
+5. ui 只能使用已有 tween preset。
+6. 每个 id 必须小写 snake_case 或 kebab-case,不能重复。
+7. feedback_spec 里引用到的角色动画、vfx、ui preset,必须都能在 asset_plan 里找到对应资产。
+```
+
+### 7.4 生成 manifest
+
+```text
+你是 Anim Studio manifest 生成器。
+请根据 game_design_spec.json、feedback_spec.json 和 asset_plan.json 输出 animation_manifest.json。
+
+要求:
+1. 只输出 JSON。
+2. 字段只能包含:game, style, characters, ui_art, vfx, ui。
+3. characters 每项必须有 id,type,animations,prompt;type 固定 spine。
+4. ui_art 每项必须有 id,transparent,size,prompt。
+5. vfx 每项必须有 id,type,template,color;type 固定 particle。
+6. ui 每项必须有 id,type,preset;type 固定 tween。
+7. prompt 必须适合图像模型直接生图,清晰、短句、单素材、无版权角色。
+8. feedback_spec 里使用的动画和特效必须在 manifest 中有对应定义。
+```
+
+## 8. 校验规则
+
+生成 manifest 后必须自动检查:
+
+- JSON 能解析。
+- `game` 非空。
+- `characters`、`ui_art`、`vfx`、`ui` 至少一类非空。
+- 所有 `id` 在同类里唯一。
+- `characters[].animations` 只能包含当前支持动画,如 `idle`、`win`。
+- `ui_art[].size` / 顶层 `size` 必须是当前模型支持尺寸。
+- `vfx[].template` 必须在粒子模板库内。
+- `ui[].preset` 必须在 tween 预设库内。
+- prompt 不得为空,不得包含“参考图同款”“复制 logo”“照抄”等风险词。
+- `feedback_spec` 引用的 `vfx` 必须存在于 manifest。
+- `feedback_spec` 引用的 `ui_animation` 必须存在于 manifest 的 `ui[].preset` 或 `ui[].id`。
+- `feedback_spec` 引用的角色动画必须存在于对应 `characters[].animations`。
+
+校验失败时不要生图,先让文字模型修 manifest。
+
+## 9. 网页功能建议
+
+建议把当前网页顶部的“生成面板”升级成四步向导:
+
+```text
+Step 1 游戏需求
+  文本需求输入 + 参考图上传 + 目标品类选择
+
+Step 2 AI 生成策划细则
+  输出 game_design_spec.json 和 feedback_spec.json,可编辑
+
+Step 3 AI 生成 manifest
+  输出 animation_manifest.json,校验通过后才允许生成
+
+Step 4 生成 / QA / 导出
+  沿用当前生成面板、资源库预览、Cocos 导出
+```
+
+第一版不需要真的理解图片内容,可以先让用户给每张参考图写一句说明。后续再接视觉模型读取参考图。
+
+## 10. 最小落地版本
+
+优先做这 4 个文件/能力:
+
+1. `game_request_template.md`:需求输入模板。
+2. `manifest_schema.py`:校验 manifest + feedback 引用。
+3. `design_prompts.py`:四段文字模型 prompt。
+4. `/api/design-to-manifest`:输入需求文本,返回 game_design_spec / feedback_spec / asset_plan / manifest。
+
+这样当前链路会变成:
+
+```text
+填需求
+  -> 生成反馈定义
+  -> 生成 manifest
+  -> 点开始生成
+  -> 预览
+  -> 导出 Cocos
+```
+
+这比一开始就做完整游戏逻辑更稳,因为先把“资源定义”标准化,后面再扩展玩法代码生成。
+
+## 11. 和“定义游戏”的边界
+
+这套流程能定义的是:
+
+- 品类与核心循环
+- 主场景 UI 结构
+- 美术风格
+- 游戏反馈层级与触发事件
+- 角色 / UI / 特效 / 动效资产清单
+- Cocos 资源包结构
+- 可运行演示场景
+
+还不能完全自动定义的是:
+
+- 真实 slot 数学模型 / RTP / 赔付表
+- 商业化、登录、钱包、合规
+- 完整关卡系统或服务器协议
+- 复杂 Spine 多部件骨骼
+
+所以阶段目标应定为:先自动生成“可看、可导入、可继续开发”的游戏原型资源包,而不是一次生成完整上线游戏。

+ 320 - 0
老虎机玩法配置工作流.md

@@ -0,0 +1,320 @@
+# 老虎เกมสล็อต玩法配置工作流
+
+目标:让用户通过点选和少量填写,定义一款有特色的竖屏电子游戏;系统再把玩法配置转换为 `feedback_spec.json`、`animation_manifest.json`、自动生图和 Cocos 原型包。
+
+注意:本流程默认用于 `demo_only` 娱乐原型。真实货币 slot 需要独立的 RTP 数学、随机数、审计、地区合规和牌照流程,不在当前自动化范围内。
+
+## 1. 调研结论:常见 slot 玩法模块
+
+主流玩法可以拆成几个可组合模块:
+
+| 模块 | 说明 | 适合做什么体验 |
+|---|---|---|
+| 固定赔线 `paylines` | 按固定线型从左到右判断中奖 | 经典、容易理解 |
+| Ways `ways` | 不看具体线,只看相邻转轴是否有同符号 | 现代、命中更频繁 |
+| Megaways `megaways` | 每轴行数随机变化,形成大量 ways | 变化感强、波动更大 |
+| Cluster Pays `clusterPays` | 网格里相邻同符号成团中奖 | 消除感、适合移动端大格子 |
+| Cascades `cascades` | 中奖符号消失,新符号下落,可连锁 | 节奏爽、容易做连续反馈 |
+| Wilds `wilds` | 万能符替代普通符号,可扩展/粘住/带倍数 | 增加期待和爆点 |
+| Scatter Free Spins | 散布符任意位置触发免费旋转 | 最常见 bonus 入口 |
+| Hold & Win / Respins | 金币/现金符锁定,重置 respin,冲 jackpot | 强目标感,适合大奖玩法 |
+| Pick Bonus | 进入选择奖励小游戏 | 互动感强,但需要额外 UI |
+| Multipliers | 连锁或 wild 带倍数 | 让小玩法有大结果 |
+
+## 2. 推荐的用户配置向导
+
+网页上不要让用户直接写完整 JSON。建议分 8 步表单。
+
+### Step 1 基本信息
+
+用户填写:
+
+- 游戏名
+- 主题:糖果 / 埃及 / 海盗 / 水果 / 赛博 / 动物 / 自定义
+- 画风:3D 果冻 / 扁平卡通 / 奢华金属 / 霓虹科幻 / 自定义
+- 目标:轻松频繁中奖 / 大奖刺激 / 连锁爽感 / bonus 丰富
+- 模式:默认 `demo_only`
+
+输出到配置:
+
+```json
+{
+  "game": {
+    "id": "jelly-candy-slot",
+    "title": "Jelly Candy Slot",
+    "mode": "demo_only",
+    "orientation": "portrait"
+  },
+  "theme": {
+    "world": "jelly candy land",
+    "visualStyle": "cute 3D jelly mobile game"
+  }
+}
+```
+
+### Step 2 选择基础转轮模式
+
+四选一:
+
+| 选择 | 自动配置 |
+|---|---|
+| 经典赔线 | `reels.mode = paylines`,5x3,25 lines |
+| 高命中 Ways | `reels.mode = ways`,5x3,243 ways |
+| 变化型 Megaways | `reels.mode = megaways`,6 reels,2-7 rows |
+| 消除型 Cluster | `reels.mode = clusterPays`,6x5,5 连成团 |
+
+第一版建议默认选 `ways + cascades`,因为它最容易做出“好玩”和“有反馈”的感觉。
+
+### Step 3 选择波动风格
+
+用户只选体验,不填复杂数学:
+
+| 选择 | 表现 | 自动倾向 |
+|---|---|---|
+| 低波动 | 经常小奖 | 高 hit feel,小倍数 |
+| 中波动 | 小奖和大奖平衡 | 默认推荐 |
+| 高波动 | 很久不出,一出很大 | bonus 和 multiplier 更重 |
+
+配置字段:
+
+```json
+{
+  "mathProfile": {
+    "volatility": "medium",
+    "hitFrequencyFeel": "medium",
+    "maxWinMultiplier": 5000,
+    "rtpTargetLabel": "demo_not_certified"
+  }
+}
+```
+
+### Step 4 选择特殊符号
+
+用复选框:
+
+- Wild
+- Scatter
+- Cash Coin
+- Collect
+- Bonus
+- Multiplier Wild
+
+每个符号都会反向要求 manifest 生成对应美术素材。
+
+### Step 5 选择核心特色玩法
+
+推荐做成“最多选 2 个主特色 + 1 个辅助特色”,避免玩法堆太多。
+
+主特色:
+
+- 连锁下落 Cascades
+- 免费旋转 Free Spins
+- Hold & Win
+- Megaways
+- Cluster Pays
+
+辅助特色:
+
+- 扩展 Wild
+- 粘性 Wild
+- 倍数递增
+- 随机变色/变符号
+- Jackpot 徽章
+
+组合推荐:
+
+| 方案 | 组合 | 风格 |
+|---|---|---|
+| 新手稳妥 | Ways + Free Spins + Expanding Wild | 易懂、产出稳定 |
+| 爽感连锁 | Ways + Cascades + Multiplier | 连击反馈强 |
+| 大奖目标 | Paylines/Ways + Hold & Win + Cash Coin | 大奖期待强 |
+| 变化刺激 | Megaways + Cascades + Free Spins | 现代、高波动 |
+| 消除休闲 | Cluster Pays + Cascades + Multiplier | 更像休闲消除 |
+
+### Step 6 定义反馈强度
+
+用户选整体手感:
+
+- 克制:少特效,重清晰
+- 标准:中奖有反馈,大奖明显
+- 夸张:金币雨、弹窗、彩纸多
+
+映射到:
+
+```json
+{
+  "feedback": {
+    "overallFeel": "elastic, bright, satisfying, not too noisy",
+    "intensity": {
+      "tap": "micro",
+      "smallWin": "normal_win",
+      "bigWin": "big_win",
+      "bonus": "bonus"
+    }
+  }
+}
+```
+
+### Step 7 选择自动生成的资产范围
+
+用户选择:
+
+- 角色/符号数量:6 / 8 / 10 / 12
+- UI 美术:基础 / 完整
+- 特效数量:基础 2 个 / 完整 4 个
+- 是否生成 logo
+- 是否生成 Cocos 演示场景
+
+### Step 8 生成与校验
+
+系统输出:
+
+```text
+slot_game_config.json
+  -> feedback_spec.json
+  -> asset_plan.json
+  -> animation_manifest.json
+  -> 生图
+  -> Cocos 包
+```
+
+## 3. 完整配置结构
+
+完整模板见:
+
+[slot_game_config_template.json](slot_game_config_template.json)
+
+核心字段:
+
+```json
+{
+  "game": {},
+  "theme": {},
+  "reels": {},
+  "mathProfile": {},
+  "symbols": {},
+  "paytable": {},
+  "features": {},
+  "playerControls": {},
+  "feedback": {},
+  "assetGeneration": {}
+}
+```
+
+## 4. 配置到 manifest 的转换规则
+
+### 4.1 `theme` -> manifest.style
+
+```text
+theme.world + theme.visualStyle + palette + avoid
+```
+
+生成全局 `style`。
+
+### 4.2 `symbols` -> manifest.characters
+
+普通符号、高价值符号、wild、scatter、cash、collect 都先作为 `characters` 生成,因为当前管线最稳定的是“单图 -> Spine 三件套”。
+
+```json
+{
+  "id": "wild",
+  "type": "spine",
+  "animations": ["idle", "win"],
+  "prompt": "a glossy rainbow jelly wild symbol icon, mobile slot game asset, centered, no text"
+}
+```
+
+### 4.3 `features` + `feedback` -> manifest.vfx / manifest.ui
+
+如果启用:
+
+- `cascades`:需要 `win_burst`
+- `scatterFreeSpins`:需要 `confetti_pop`、`bigwin_glow`
+- `holdAndWin`:需要 `coin_rain`、`bigwin_glow`
+- `big_win` 反馈:需要 `coin_rain`、`confetti_pop`
+
+### 4.4 `assetGeneration.uiArt` -> manifest.ui_art
+
+固定 UI 资产:
+
+- `bg_main`
+- `logo`
+- `reel_frame`
+- `btn_spin`
+- `btn_round`
+- `hud_pill`
+- `win_popup`
+- `free_spin_badge`
+
+## 5. 推荐默认配置
+
+第一版建议默认:
+
+```json
+{
+  "reels": { "mode": "ways", "columns": 5, "rows": 3 },
+  "features": {
+    "wilds": { "enabled": true, "variant": "expanding" },
+    "scatterFreeSpins": { "enabled": true },
+    "cascades": { "enabled": true },
+    "holdAndWin": { "enabled": false }
+  },
+  "mathProfile": {
+    "volatility": "medium",
+    "hitFrequencyFeel": "medium"
+  }
+}
+```
+
+原因:
+
+- 比固定赔线更现代。
+- 比 Megaways 更容易做稳定原型。
+- Cascades 能自然带出连锁反馈。
+- Free Spins 是用户最熟悉的 bonus。
+- Hold & Win 可以作为第二套模板,不要第一版就混进去。
+
+## 6. 可玩特色的生成策略
+
+“好玩而有特色”不是靠随机堆功能,而是每个游戏选一个核心钩子:
+
+| 核心钩子 | 配置方式 | 视觉表现 |
+|---|---|---|
+| 果冻连锁 | Ways + Cascades + multiplier step | 符号弹走、新符号掉落、连击倍率 |
+| 金币大奖 | Hold & Win + cash coin + collect | 金币锁格、收集、jackpot 徽章 |
+| 免费旋转爽感 | Scatter + Free Spins + more wilds | 免费旋转转场、wild 变多 |
+| 变形转轮 | Megaways + row randomizer | 每次 spin 行数变化、转轮拉伸 |
+| 消除休闲 | Cluster Pays + Cascades | 同色成团爆开、掉落补位 |
+
+每次只选一个“主钩子”,否则 Cocos 原型、反馈、资产都会变复杂。
+
+## 7. 网页实现建议
+
+新增一个“定义游戏”页签:
+
+```text
+玩法类型       单选:赔线 / Ways / Megaways / Cluster
+转轮规格       columns / rows 或 rowRange
+波动风格       低 / 中 / 高
+特殊符号       Wild / Scatter / Cash / Collect / Bonus
+特色玩法       Cascades / Free Spins / Hold & Win / Multipliers
+反馈强度       克制 / 标准 / 夸张
+资产范围       角色数量、UI 完整度、特效数量
+```
+
+按钮:
+
+```text
+生成玩法配置
+生成 manifest
+开始生图
+导出 Cocos 包
+```
+
+第一版不需要实现真实数学,只需要生成:
+
+- 可解释的玩法配置
+- 对应资源和反馈
+- Cocos 可运行演示场景
+
+真实 slot 数学以后单独做 `math_engine_config.json`。