index.html 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041
  1. <!DOCTYPE html>
  2. <html lang="zh">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Anim Studio · 动画资源库</title>
  7. <style>
  8. :root{
  9. --bg:#1b1430; --panel:#241a3d; --card:#2c2150; --line:#3d2f63;
  10. --accent:#ff7eb6; --accent2:#7ee8ff; --text:#f3eefb; --muted:#a99ccb;
  11. }
  12. *{box-sizing:border-box}
  13. body{margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
  14. background:linear-gradient(160deg,#1b1430,#2a1b45);color:var(--text);min-height:100vh}
  15. header{padding:18px 24px;border-bottom:1px solid var(--line);display:flex;
  16. align-items:center;gap:14px}
  17. header h1{font-size:20px;margin:0}
  18. header .sub{color:var(--muted);font-size:13px}
  19. .wrap{max-width:1180px;margin:0 auto;padding:20px 24px 60px}
  20. details.gen{background:var(--panel);border:1px solid var(--line);border-radius:14px;
  21. padding:0 18px;margin-bottom:22px}
  22. details.gen summary{cursor:pointer;padding:16px 0;font-weight:600;font-size:15px;
  23. list-style:none}
  24. details.gen summary::-webkit-details-marker{display:none}
  25. .gen-grid{display:grid;grid-template-columns:320px 1fr;gap:18px;padding:0 0 18px}
  26. .workflow-grid{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:12px;padding:0 0 18px}
  27. .workflow-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:0 0 18px}
  28. .workflow-note{color:var(--muted);font-size:12px;line-height:1.5}
  29. .field{margin-bottom:10px}
  30. .field label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
  31. .field input,.field select,textarea{width:100%;background:#160f29;color:var(--text);
  32. border:1px solid var(--line);border-radius:8px;padding:9px 10px;font-size:13px}
  33. .field input[type="checkbox"]{width:auto;margin-right:6px;vertical-align:-1px}
  34. textarea{font-family:ui-monospace,Menlo,monospace;min-height:300px;resize:vertical;line-height:1.45}
  35. button{background:linear-gradient(90deg,var(--accent),#ff9d6e);color:#2a0f23;border:none;
  36. border-radius:9px;padding:10px 18px;font-weight:700;cursor:pointer;font-size:14px}
  37. button.ghost{background:#2c2150;color:var(--text);border:1px solid var(--line);font-weight:600}
  38. button:disabled{opacity:.5;cursor:default}
  39. .log{white-space:pre-wrap;background:#0f0a1e;border:1px solid var(--line);border-radius:8px;
  40. padding:10px;font-family:ui-monospace,monospace;font-size:12px;color:#bfe;max-height:160px;
  41. overflow:auto;margin-top:10px}
  42. .tabs{display:flex;gap:8px;margin:6px 0 18px}
  43. .tabs button{background:#241a3d;color:var(--muted);border:1px solid var(--line)}
  44. .tabs button.active{background:var(--accent);color:#2a0f23}
  45. .toolbar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
  46. .toolbar select{background:#160f29;color:var(--text);border:1px solid var(--line);
  47. border-radius:8px;padding:7px 10px;font-size:13px}
  48. .count{color:var(--muted);font-size:13px}
  49. .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
  50. .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;
  51. display:flex;flex-direction:column;gap:10px}
  52. .card.missing{border-style:dashed;background:#241a3d}
  53. .card.running{border-color:var(--accent2);box-shadow:0 0 0 1px rgba(126,232,255,.35),0 0 18px rgba(126,232,255,.18)}
  54. .task-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
  55. .task-status{border-radius:20px;padding:2px 9px;font-size:11px;white-space:nowrap;border:1px solid var(--line)}
  56. .task-status.done{color:#96ffce;background:#10291f}
  57. .task-status.missing{color:#ffd0df;background:#3d1830}
  58. .task-status.invalid{color:#ffd87a;background:#3c2810}
  59. .task-status.running{color:#7ee8ff;background:#112d3a}
  60. .qa-list{background:#241426;border:1px solid #6f3c56;color:#ffd6e6;border-radius:8px;
  61. padding:8px;font-size:11px;line-height:1.45}
  62. .task-prompt{max-height:94px;overflow:auto;background:#160f29;border:1px solid var(--line);
  63. border-radius:8px;padding:8px;color:#cfc4ec;font-size:11px;line-height:1.45;white-space:pre-wrap}
  64. .placeholder{width:82%;height:82%;border:1px dashed #6b5c92;border-radius:12px;display:flex;
  65. align-items:center;justify-content:center;text-align:center;color:var(--muted);font-size:13px;padding:14px}
  66. .stage{height:200px;border-radius:10px;background:
  67. repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/22px 22px;
  68. display:flex;align-items:flex-end;justify-content:center;overflow:hidden;position:relative}
  69. .stage.clickable{cursor:pointer}
  70. .stage img{max-height:88%;max-width:88%;transform-origin:50% 100%;will-change:transform;
  71. filter:drop-shadow(0 6px 10px rgba(0,0,0,.35))}
  72. .stage.invalid-preview img{opacity:.46;filter:grayscale(.25) drop-shadow(0 6px 10px rgba(0,0,0,.35))}
  73. .stage-badge{position:absolute;left:10px;top:10px;background:#3c2810;color:#ffd87a;
  74. border:1px solid #80622d;border-radius:999px;padding:4px 9px;font-size:11px;font-weight:700}
  75. .stage canvas{position:absolute;inset:0;width:100%;height:100%}
  76. .name{font-weight:700;font-size:15px}
  77. .meta{color:var(--muted);font-size:12px;line-height:1.5}
  78. .row{display:flex;gap:8px;align-items:center}
  79. .row select{flex:1;background:#160f29;color:var(--text);border:1px solid var(--line);
  80. border-radius:7px;padding:6px 8px;font-size:12px}
  81. .pill{display:inline-block;background:#160f29;border:1px solid var(--line);border-radius:20px;
  82. padding:2px 10px;font-size:11px;color:var(--accent2)}
  83. .art-img{max-width:100%;max-height:180px;object-fit:contain}
  84. .demo-box{width:84px;height:84px;border-radius:14px;
  85. background:linear-gradient(135deg,var(--accent),var(--accent2));margin:auto;
  86. display:flex;align-items:center;justify-content:center;font-weight:800;color:#241a3d;font-size:22px}
  87. .empty{color:var(--muted);text-align:center;padding:50px;border:1px dashed var(--line);border-radius:14px}
  88. .modal{position:fixed;inset:0;background:rgba(8,5,18,.78);display:none;align-items:center;
  89. justify-content:center;padding:28px;z-index:20}
  90. .modal.open{display:flex}
  91. .modal-panel{width:min(980px,96vw);max-height:92vh;overflow:auto;background:var(--panel);
  92. border:1px solid var(--line);border-radius:14px;padding:18px}
  93. .modal-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
  94. .modal-head h3{margin:0;font-size:18px}
  95. .parts-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
  96. .parts-box{background:#160f29;border:1px solid var(--line);border-radius:10px;padding:12px}
  97. .parts-box img{width:100%;max-height:520px;object-fit:contain;
  98. background:repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/22px 22px;border-radius:8px}
  99. .parts-list{grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px}
  100. .part-card{background:#160f29;border:1px solid var(--line);border-radius:9px;padding:8px;min-width:0}
  101. .part-card img{width:100%;height:92px;object-fit:contain;background:
  102. repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/18px 18px;border-radius:7px}
  103. .part-card .meta{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:5px}
  104. .part-card button{width:100%;padding:7px 8px;margin-top:7px;font-size:12px}
  105. code{background:#160f29;padding:1px 6px;border-radius:5px;font-size:12px}
  106. a{color:var(--accent2)}
  107. @media(max-width:760px){.parts-grid{grid-template-columns:1fr}}
  108. </style>
  109. </head>
  110. <body>
  111. <header>
  112. <h1>🍬 Anim Studio</h1>
  113. <span class="sub">动画资源库 · 角色 / 特效 / 动效 可视化预览</span>
  114. </header>
  115. <div class="wrap">
  116. <details class="gen" id="workflowPanel" open>
  117. <summary>▸ AI 游戏方案(创意 + 风格 + 玩法 → manifest)</summary>
  118. <div class="field"><label>创意简报(可填一句话,也可以写完整需求)</label>
  119. <textarea id="creativeBrief" style="min-height:92px" placeholder="例:做一个竖屏海盗金币 slot,参考糖果传奇那种明亮可爱的反馈,但主题是热带宝藏。核心钩子是金币 Hold & Win,整体要轻松、亮、适合移动端。"></textarea></div>
  120. <div class="workflow-grid">
  121. <div class="field"><label>参考图 / 网址链接(一行一个)</label>
  122. <textarea id="creativeRefs" style="min-height:92px" placeholder="https://...\n也可以写:本地截图文件名、参考页面链接、竞品链接"></textarea></div>
  123. <div class="field"><label>上传参考图 / 截图(最多 4 张)</label>
  124. <input id="creativeImageFiles" type="file" accept="image/*" multiple>
  125. <div class="workflow-note" id="creativeImageMsg">上传图会先由视觉模型提炼风格,再生成 manifest。</div></div>
  126. <div class="field"><label>希望参考的风格点</label>
  127. <textarea id="creativeStyleNotes" style="min-height:92px" placeholder="按钮质感、颜色、角色比例、背景气氛、UI 密度等"></textarea></div>
  128. <div class="field"><label>必须避免</label>
  129. <textarea id="creativeAvoidNotes" style="min-height:92px" placeholder="不要照抄 logo / IP / 角色;不要暗黑;不要复杂文字"></textarea></div>
  130. <div>
  131. <div class="field"><label>文字模型</label>
  132. <input id="textModel" value="gpt-5.4-mini"></div>
  133. <button class="ghost" id="aiWorkflowBtn">AI 生成完整游戏方案</button>
  134. <div class="workflow-note" id="aiWorkflowMsg">先生成统一方案,再微调玩法和图片资源。</div>
  135. </div>
  136. </div>
  137. <div class="log" id="gamePlanView" style="display:none;max-height:260px"></div>
  138. <div class="workflow-grid">
  139. <div class="field"><label>游戏代号</label>
  140. <input id="wfGameId" value="jelly-candy-slot"></div>
  141. <div class="field"><label>游戏标题</label>
  142. <input id="wfTitle" value="Jelly Candy Slot"></div>
  143. <div class="field"><label>主题</label>
  144. <select id="wfTheme">
  145. <option value="jelly">果冻糖果</option>
  146. <option value="fruit">水果乐园</option>
  147. <option value="egypt">埃及宝藏</option>
  148. <option value="pirate">海盗金币</option>
  149. <option value="pirate_jelly">海盗糖果</option>
  150. <option value="cyber">霓虹赛博</option>
  151. </select></div>
  152. <div class="field"><label>基础玩法</label>
  153. <select id="wfReelMode">
  154. <option value="ways">Ways 5×3</option>
  155. <option value="paylines">固定赔线 5×3</option>
  156. <option value="megaways">Megaways 6轴</option>
  157. <option value="cluster">Cluster Pays 6×5</option>
  158. </select></div>
  159. <div class="field"><label>波动风格</label>
  160. <select id="wfVolatility">
  161. <option value="medium">中波动</option>
  162. <option value="low">低波动</option>
  163. <option value="high">高波动</option>
  164. </select></div>
  165. <div class="field"><label>目标 RTP(%)</label>
  166. <input id="wfTargetRtp" type="number" value="96" min="50" max="99.5" step="0.1"></div>
  167. <div class="field"><label><input type="checkbox" id="wfEnableMathModel" checked> 生成赔率和触发概率数学模型</label></div>
  168. <div class="field"><label>角色/符号数量</label>
  169. <select id="wfCharacterCount">
  170. <option>10</option><option>8</option><option>12</option><option>6</option>
  171. </select></div>
  172. <div class="field"><label>UI 资产范围</label>
  173. <select id="wfUiCompleteness">
  174. <option value="full">完整</option>
  175. <option value="basic">基础</option>
  176. </select></div>
  177. <div class="field"><label>反馈强度</label>
  178. <select id="wfFeedbackIntensity">
  179. <option value="standard">标准</option>
  180. <option value="quiet">克制</option>
  181. <option value="loud">夸张</option>
  182. </select></div>
  183. <div class="field"><label><input type="checkbox" id="wfEnableBoss" checked> 生成关主大魔王</label></div>
  184. <div class="field"><label>关主表现</label>
  185. <select id="wfBossPresence">
  186. <option value="full">完整:待机/撒币/裂开/踩踏</option>
  187. <option value="standard">标准:输赢反应</option>
  188. <option value="light">轻量:只做待机和输赢</option>
  189. </select></div>
  190. <div class="field"><label>初始金币</label>
  191. <input id="wfStartingBalance" type="number" value="5000" min="100" step="100"></div>
  192. <div class="field"><label>默认下注</label>
  193. <input id="wfDefaultBet" type="number" value="50" min="1" step="10"></div>
  194. <div class="field"><label>免费旋转次数</label>
  195. <input id="wfFreeSpinCount" type="number" value="8" min="3" max="30"></div>
  196. <div class="field"><label>最大连锁次数</label>
  197. <input id="wfMaxCascades" type="number" value="6" min="1" max="20"></div>
  198. <div class="field"><label><input type="checkbox" id="wfCascades" checked> Cascades 连锁下落</label></div>
  199. <div class="field"><label><input type="checkbox" id="wfFreeSpins" checked> Scatter Free Spins</label></div>
  200. <div class="field"><label><input type="checkbox" id="wfWilds" checked> Wild 符号</label></div>
  201. <div class="field"><label><input type="checkbox" id="wfHoldWin"> Hold & Win</label></div>
  202. <div class="field"><label><input type="checkbox" id="wfMultipliers"> 连锁倍数</label></div>
  203. </div>
  204. <div class="workflow-actions">
  205. <button id="buildWorkflowBtn">生成玩法配置和 manifest</button>
  206. <button class="ghost" id="openGenBtn">打开生成面板</button>
  207. <span class="workflow-note" id="workflowMsg">先生成 manifest,再点下方“开始生成”。</span>
  208. </div>
  209. </details>
  210. <details class="gen" id="genPanel" open>
  211. <summary>▸ 生成图片资源(填 key,点开始)</summary>
  212. <div class="gen-grid">
  213. <div>
  214. <div class="field"><label>接口协议(厂商)</label>
  215. <select id="provider"><option>OpenAI 兼容接口</option></select></div>
  216. <div class="field"><label>API Key</label>
  217. <input id="apiKey" type="password" placeholder="已内置,留空即可;临时换 key 时再填写"></div>
  218. <div class="field"><label>Base URL</label>
  219. <input id="baseUrl" value="https://x.long.bid/v1"></div>
  220. <div class="field"><label>模型名(真正发给 API 的,按你的填)</label>
  221. <input id="model" value="gpt-image-2"></div>
  222. <div class="field"><label>尺寸</label>
  223. <select id="size"><option>1024x1024</option><option>1024x1536</option><option>1536x1024</option></select></div>
  224. <button id="startBtn">▶ 开始生成</button>
  225. <div class="log" id="log" style="display:none"></div>
  226. </div>
  227. <div class="field"><label>animation_manifest.json</label>
  228. <textarea id="manifest"></textarea></div>
  229. </div>
  230. </details>
  231. <div class="toolbar">
  232. <span class="meta">资源库(game):</span>
  233. <select id="gameSel"></select>
  234. <button class="ghost" id="reloadBtn">↻ 刷新</button>
  235. <button id="retryMissingBtn">批量补生成缺失项</button>
  236. <button class="ghost" id="openFolderBtn">📂 打开素材目录</button>
  237. <button id="exportBtn">📦 导出 Cocos 整合包</button>
  238. <button class="ghost" id="deleteBtn">🗑 删除该资源库</button>
  239. <span class="count" id="opMsg"></span>
  240. </div>
  241. <div class="tabs">
  242. <button data-tab="chars" class="active">角色库</button>
  243. <button data-tab="art">UI 美术</button>
  244. <button data-tab="vfx">特效库</button>
  245. <button data-tab="ui">动效库</button>
  246. </div>
  247. <div id="view"></div>
  248. </div>
  249. <div class="modal" id="partsModal">
  250. <div class="modal-panel">
  251. <div class="modal-head">
  252. <h3 id="partsTitle">拆件预览</h3>
  253. <div class="row">
  254. <button class="ghost" id="partsRebuildAll">按主图重生全部拆件</button>
  255. <button class="ghost" id="partsClose">关闭</button>
  256. </div>
  257. </div>
  258. <div class="parts-grid">
  259. <div class="parts-box" style="grid-column:1/-1">
  260. <div class="name">拆件图 / Atlas</div>
  261. <img id="partsAtlas" alt="拆件图">
  262. </div>
  263. <div class="parts-box" style="grid-column:1/-1">
  264. <div class="name">单独拆件 PNG</div>
  265. <div class="meta" id="partsCount"></div>
  266. <div class="parts-list" id="partsList"></div>
  267. </div>
  268. </div>
  269. </div>
  270. </div>
  271. <script>
  272. const $ = s => document.querySelector(s);
  273. let LIB = {characters:[],vfx:[],ui:[]}, ASSET="", ASSET_VER=Date.now(), TAB="chars";
  274. const animTargets = []; // {el, anim, start}
  275. const ACTIVE_TASKS = new Set();
  276. let CURRENT_PARTS = null;
  277. const esc = s => String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
  278. const tasksFor = kind => ((LIB.tasks&&LIB.tasks[kind]) || []);
  279. const assetUrl = path => ASSET + path + (path.includes('?')?'&':'?') + 'v=' + ASSET_VER;
  280. const taskKey = (kind,id) => `${kind}:${id}`;
  281. function missingTasks(){
  282. return ['characters','ui_art','vfx','ui'].flatMap(kind => tasksFor(kind)
  283. .filter(t=>t.status!=='done').map(t=>({kind,id:t.id})));
  284. }
  285. function markTasksRunning(items, running=true){
  286. items.forEach(t=>running?ACTIVE_TASKS.add(taskKey(t.kind,t.id)):ACTIVE_TASKS.delete(taskKey(t.kind,t.id)));
  287. }
  288. function openPartsModal(c, t){
  289. CURRENT_PARTS = {asset:c, task:t};
  290. $('#partsRebuildAll').dataset.boss = c.id || t.id;
  291. $('#partsTitle').textContent = `${t.chineseName} · 拆件检查`;
  292. $('#partsAtlas').src = assetUrl(c.png);
  293. const parts = c.parts || [];
  294. $('#partsCount').textContent = parts.length ? `已拆出 ${parts.length} 个单独 PNG,可逐个检查。` : '没有单独拆件 PNG;请重新生成该关主。';
  295. $('#partsList').innerHTML = parts.length
  296. ? parts.map(p=>`<div class="part-card"><img src="${assetUrl(p.file)}" alt="${esc(p.id)}"><div class="meta">${esc(p.id)} · ${esc(p.w||'?')}×${esc(p.h||'?')}</div><button class="ghost part-retry-btn" data-boss="${esc(c.id)}" data-part="${esc(p.id)}">重生该拆件</button></div>`).join('')
  297. : '<div class="empty">缺少单独拆件文件。</div>';
  298. $('#partsModal').classList.add('open');
  299. }
  300. function syncRunningWithLibrary(){
  301. for(const key of [...ACTIVE_TASKS]){
  302. const [kind,id] = key.split(':');
  303. const row = tasksFor(kind).find(t=>t.id===id);
  304. if(row && row.status==='done') ACTIVE_TASKS.delete(key);
  305. }
  306. }
  307. // ---------- 拉取默认 manifest & 资源库 ----------
  308. async function loadManifest(){
  309. try{ const t = await (await fetch('/api/manifest')).text(); $('#manifest').value = t; }catch(e){}
  310. }
  311. async function loadLibrary(game, opts={}){
  312. const url = '/api/library' + (game?('?game='+encodeURIComponent(game)):'');
  313. const lib = await (await fetch(url)).json();
  314. LIB = lib; ASSET = lib.assetBase || ""; ASSET_VER = Date.now();
  315. syncRunningWithLibrary();
  316. const sel = $('#gameSel'); sel.innerHTML='';
  317. const games = [...(lib.games||[])];
  318. const pending = lib.game && !games.includes(lib.game);
  319. if(pending) games.push(lib.game);
  320. games.forEach(g=>{ const o=document.createElement('option');
  321. o.value=g;o.textContent=g+(pending&&g===lib.game?'(待生成)':''); if(g===lib.game)o.selected=true; sel.appendChild(o); });
  322. if(!games.length){ const o=document.createElement('option');o.textContent='(暂无)';sel.appendChild(o); }
  323. if(!opts.silent){
  324. if(pending) opMsg(`已载入 ${lib.game} 的 manifest,但还没有图片资源;点“开始生成”后才会进入资源库。`, false);
  325. if(lib.taskSummary && !pending){
  326. opMsg(`任务 ${lib.taskSummary.done}/${lib.taskSummary.total} 已完成,缺失 ${lib.taskSummary.missing} 个。`, lib.taskSummary.missing===0);
  327. }
  328. }
  329. $('#retryMissingBtn').disabled = !lib.taskSummary || !lib.taskSummary.missing || ACTIVE_TASKS.size>0;
  330. const boss = lib.slot_config && lib.slot_config.boss;
  331. if(boss && boss.enabled){
  332. const bossId = boss.id || 'boss_demon_lord';
  333. const hasBoss = (lib.characters||[]).some(c=>c.id===bossId);
  334. if(!hasBoss && !opts.silent) opMsg(`当前资源库缺少关主大魔王 ${bossId},在角色库任务卡片点“补生成 / 重试”即可继续。`, false);
  335. }
  336. render();
  337. }
  338. // ---------- 关键帧采样(与后端 spine 数据一致)----------
  339. const lerp=(a,b,t)=>a+(b-a)*t;
  340. function sample(keys,t,field){
  341. if(!keys||!keys.length) return field==='value'?0:1;
  342. if(t<=keys[0].time) return keys[0][field];
  343. for(let i=0;i<keys.length-1;i++){
  344. if(t>=keys[i].time && t<=keys[i+1].time){
  345. const f=(t-keys[i].time)/((keys[i+1].time-keys[i].time)||1);
  346. return lerp(keys[i][field],keys[i+1][field],f);
  347. }
  348. }
  349. return keys[keys.length-1][field];
  350. }
  351. function tick(now){
  352. for(const t of animTargets){
  353. if(!t.el.isConnected) continue;
  354. const a=t.anim; if(!a){continue;}
  355. const dur=a.duration||1; const time=((now-t.start)/1000)%dur;
  356. const sx=sample(a.scale,time,'x'), sy=sample(a.scale,time,'y');
  357. const rot=sample(a.rotate,time,'value');
  358. t.el.style.transform=`scaleX(${sx}) scaleY(${sy}) rotate(${(-rot).toFixed(2)}deg)`;
  359. }
  360. requestAnimationFrame(tick);
  361. }
  362. requestAnimationFrame(tick);
  363. // ---------- 渲染 ----------
  364. function render(){
  365. const v=$('#view'); v.innerHTML=''; animTargets.length=0;
  366. if(TAB==='chars') renderChars(v);
  367. if(TAB==='art') renderArt(v);
  368. if(TAB==='vfx') renderVfx(v);
  369. if(TAB==='ui') renderUi(v);
  370. }
  371. function renderChars(v){
  372. const list=tasksFor('characters');
  373. if(!list.length){ v.innerHTML='<div class="empty">还没有角色任务。先生成 manifest。</div>'; return; }
  374. const grid=document.createElement('div'); grid.className='grid';
  375. list.forEach(t=>{
  376. const c=t.asset||{};
  377. const done=t.status==='done' && c.png;
  378. const usable=(t.status==='done'||t.status==='invalid') && c.png;
  379. const running=ACTIVE_TASKS.has(taskKey('characters',t.id));
  380. const animMap=c.animations||{};
  381. const anims=Object.keys(animMap);
  382. const displayImg = c.preview || c.png;
  383. const isParts = c.type==='spine_parts' || t.assetType==='spine_parts';
  384. const partCount = (c.parts || []).length;
  385. const qaErrors=(t.quality&&t.quality.errors)||[];
  386. const statusClass=running?'running':(t.status==='invalid'?'invalid':(done?'done':'missing'));
  387. const statusText=running?'生成中':(t.status==='invalid'?'需修复':(done?'已生成':'缺失'));
  388. const card=document.createElement('div'); card.className='card '+(usable?'':'missing')+(running?' running':'');
  389. const stageClass = `stage ${usable&&isParts?'clickable':''} ${t.status==='invalid'?'invalid-preview':''}`;
  390. card.innerHTML=`
  391. <div class="${stageClass}">${usable?`<img src="${assetUrl(displayImg)}" alt="${esc(t.id)}">${t.status==='invalid'?'<div class="stage-badge">预览未通过</div>':''}`:`<div class="placeholder">${running?'生成中…':'待生成'}<br>${esc(t.chineseName)}</div>`}</div>
  392. <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
  393. <span class="task-status ${statusClass}">${statusText}</span></div>
  394. ${usable&&!isParts&&anims.length?`<div class="row"><select>${anims.map(a=>`<option>${esc(a)}</option>`).join('')}</select></div>`:''}
  395. <div class="meta"><span class="pill">${esc(t.assetType||'spine')}</span> ${isParts?`<span class="pill">拆件 ${partCount}</span> `:''}${esc(t.use)}<br>
  396. ${isParts?'Cocos 中播放真实骨骼动作;此处只做静态资源检查。':'动作: '+esc((done?anims:t.animations||[]).join(', ')||'idle')}</div>
  397. ${qaErrors.length?`<div class="qa-list">${qaErrors.slice(0,3).map(x=>`<div>需修复:${esc(x)}</div>`).join('')}</div>`:''}
  398. ${usable&&isParts?'<div class="row"><button class="ghost parts-btn">查看拆件图</button><button class="ghost preview-btn" data-boss="'+esc(t.id)+'">重生主图</button></div><button class="ghost parts-from-preview-btn" data-boss="'+esc(t.id)+'">按主图重生全部拆件</button>':''}
  399. <div class="task-prompt">${esc(t.prompt||'')}</div>
  400. <button class="ghost retry-btn" data-kind="characters" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':(usable?'重新生成':'补生成 / 重试')}</button>`;
  401. if(usable&&!isParts&&anims.length){
  402. const img=card.querySelector('img'), selA=card.querySelector('select');
  403. const tgt={el:img, anim:animMap[anims[0]], start:performance.now()};
  404. animTargets.push(tgt);
  405. selA.onchange=()=>{ tgt.anim=animMap[selA.value]; tgt.start=performance.now(); };
  406. }
  407. if(usable&&isParts){
  408. card.querySelector('.stage').onclick=()=>openPartsModal(c,t);
  409. card.querySelector('.parts-btn').onclick=()=>openPartsModal(c,t);
  410. }
  411. grid.appendChild(card);
  412. });
  413. v.appendChild(grid);
  414. }
  415. function renderArt(v){
  416. const list=tasksFor('ui_art');
  417. if(!list.length){ v.innerHTML='<div class="empty">还没有 UI 美术任务。用工作流生成 manifest 后点「开始生成」。</div>'; return; }
  418. const grid=document.createElement('div'); grid.className='grid';
  419. list.forEach(t=>{
  420. const a=t.asset||{};
  421. const done=t.status==='done' && a.file;
  422. const running=ACTIVE_TASKS.has(taskKey('ui_art',t.id));
  423. const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
  424. card.innerHTML=`
  425. <div class="stage">${done?`<img class="art-img" src="${assetUrl(a.file)}" alt="${esc(t.id)}">`:`<div class="placeholder">${running?'生成中…':'待生成'}<br>${esc(t.chineseName)}</div>`}</div>
  426. <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
  427. <span class="task-status ${done?'done':(running?'running':'missing')}">${done?'已生成':(running?'生成中':'缺失')}</span></div>
  428. <div class="meta">${done?`${a.w}×${a.h}px · ${a.transparent?'透明素材':'整图背景'}`:`${esc(t.size||'')} · ${t.transparent?'透明素材':'整图背景'}`}<br>
  429. <span class="pill">ui_art</span> ${esc(t.use)}</div>
  430. <div class="task-prompt">${esc(t.prompt||'')}</div>
  431. <button class="ghost retry-btn" data-kind="ui_art" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':(done?'重新生成':'补生成 / 重试')}</button>`;
  432. grid.appendChild(card);
  433. });
  434. v.appendChild(grid);
  435. }
  436. function renderVfx(v){
  437. const list=tasksFor('vfx');
  438. if(!list.length){ v.innerHTML='<div class="empty">还没有特效。</div>'; return; }
  439. const grid=document.createElement('div'); grid.className='grid';
  440. list.forEach(t=>{
  441. const x=t.asset||{};
  442. const done=t.status==='done' && x.config;
  443. const running=ACTIVE_TASKS.has(taskKey('vfx',t.id));
  444. const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
  445. card.innerHTML=`
  446. <div class="stage">${done?'<canvas></canvas>':`<div class="placeholder">${running?'生成中…':'待生成'}<br>${esc(t.chineseName)}</div>`}</div>
  447. <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
  448. <span class="task-status ${done?'done':(running?'running':'missing')}">${done?'已生成':(running?'生成中':'缺失')}</span></div>
  449. <div class="meta"><span class="pill">particle</span> ${done?`模板: ${esc(x.template)} · 发射率 ${x.config.emissionRate}/s · 寿命 ${x.config.life}s`:esc(t.use)}</div>
  450. <div class="task-prompt">${esc(t.prompt||'')}</div>
  451. <button class="ghost retry-btn" data-kind="vfx" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':(done?'重新生成':'补生成 / 重试')}</button>`;
  452. grid.appendChild(card);
  453. if(done) startParticle(card.querySelector('canvas'), x.config);
  454. });
  455. v.appendChild(grid);
  456. }
  457. function startParticle(canvas, cfg){
  458. const dpr=window.devicePixelRatio||1;
  459. function resize(){ canvas.width=canvas.clientWidth*dpr; canvas.height=canvas.clientHeight*dpr; }
  460. setTimeout(resize,0); window.addEventListener('resize',resize);
  461. const ctx=canvas.getContext('2d');
  462. const ps=[]; let acc=0, last=performance.now();
  463. const sc=(cfg.startColor||[255,255,255,255]), ec=(cfg.endColor||[255,255,255,0]);
  464. const rand=(v)=>(Math.random()*2-1)*(v||0);
  465. function spawn(){
  466. const W=canvas.width,H=canvas.height;
  467. const cx=W/2+rand(cfg.posVarX)*dpr, cy=H*0.5+rand(cfg.posVarY)*dpr;
  468. const ang=((cfg.angle||90)+rand(cfg.angleVar))*Math.PI/180;
  469. const sp=((cfg.speed||100)+rand(cfg.speedVar))*dpr*0.5;
  470. ps.push({x:cx,y:cy,vx:Math.cos(ang)*sp,vy:-Math.sin(ang)*sp,
  471. life:(cfg.life||1)+rand(cfg.lifeVar),age:0,
  472. ss:(cfg.startSize||20),es:(cfg.endSize!=null?cfg.endSize:cfg.startSize||20)});
  473. }
  474. function loop(now){
  475. if(!canvas.isConnected) return;
  476. const dt=Math.min(0.05,(now-last)/1000); last=now;
  477. const W=canvas.width,H=canvas.height;
  478. acc+=(cfg.emissionRate||60)*dt;
  479. while(acc>=1 && ps.length<260){ acc-=1; spawn(); }
  480. ctx.clearRect(0,0,W,H); ctx.globalCompositeOperation='lighter';
  481. for(let i=ps.length-1;i>=0;i--){
  482. const p=ps[i]; p.age+=dt;
  483. if(p.age>=p.life){ ps.splice(i,1); continue; }
  484. p.vy+=(-(cfg.gravityY||0))*dpr*dt; p.vx+=(cfg.gravityX||0)*dpr*dt;
  485. p.x+=p.vx*dt; p.y+=p.vy*dt;
  486. const f=p.age/p.life;
  487. const r=Math.max(0.5,(p.ss+(p.es-p.ss)*f))*dpr/2;
  488. 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));
  489. const a=lerp((sc[3]??255),(ec[3]??0),f)/255;
  490. ctx.beginPath();ctx.fillStyle=`rgba(${cr},${cg},${cb},${a})`;
  491. ctx.arc(p.x,p.y,r,0,7);ctx.fill();
  492. }
  493. requestAnimationFrame(loop);
  494. }
  495. requestAnimationFrame(loop);
  496. }
  497. // ---------- UI tween 预览(JS 复刻,仅用于看效果)----------
  498. const EASE={
  499. cubicOut:t=>1-Math.pow(1-t,3),
  500. backOut:t=>{const c=1.7;return 1+(c+1)*Math.pow(t-1,3)+c*Math.pow(t-1,2);},
  501. elasticOut:t=>t===0?0:t===1?1:Math.pow(2,-10*t)*Math.sin((t*10-0.75)*(2*Math.PI/3))+1,
  502. sineInOut:t=>-(Math.cos(Math.PI*t)-1)/2,
  503. linear:t=>t,
  504. };
  505. function animate(dur,ease,fn){
  506. const s=performance.now();
  507. function step(now){const t=Math.min(1,(now-s)/(dur*1000));fn(EASE[ease](t));if(t<1)requestAnimationFrame(step);}
  508. requestAnimationFrame(step);
  509. }
  510. const TWEEN_DEMO={
  511. scale_bounce:(el)=>{animate(0.09,'linear',t=>el.style.transform=`scale(${1-0.1*t})`);
  512. setTimeout(()=>animate(0.12,'backOut',t=>el.style.transform=`scale(${0.9+0.15*t})`),90);
  513. setTimeout(()=>animate(0.08,'linear',t=>el.style.transform=`scale(${1.05-0.05*t})`),210);},
  514. elastic_in:(el)=>{el.style.opacity=1;animate(0.45,'elasticOut',t=>el.style.transform=`scale(${t})`);},
  515. fade_slide_in:(el)=>{animate(0.3,'cubicOut',t=>{el.style.opacity=t;el.style.transform=`translateY(${40*(1-t)}px)`;});},
  516. number_roll:(el)=>{el.dataset.role='num';animate(0.8,'cubicOut',t=>el.textContent=Math.floor(8888*t));},
  517. pulse:(el)=>{let on=true;el._pi&&clearInterval(el._pi);
  518. 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);},
  519. };
  520. function renderUi(v){
  521. const list=tasksFor('ui');
  522. if(!list.length){ v.innerHTML='<div class="empty">还没有 UI 动效。已生成的会编译进 <code>ui/TweenPresets.ts</code>。</div>'; return; }
  523. const grid=document.createElement('div'); grid.className='grid';
  524. list.forEach(t=>{
  525. const u=t.asset||{};
  526. const done=t.status==='done';
  527. const running=ACTIVE_TASKS.has(taskKey('ui',t.id));
  528. const preset=done?u.preset:t.prompt;
  529. const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
  530. card.innerHTML=`<div class="stage">${done?`<div class="demo-box">${preset==='number_roll'?'0':'UI'}</div>`:`<div class="placeholder">${running?'生成中…':'待生成'}<br>${esc(t.chineseName)}</div>`}</div>
  531. <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
  532. <span class="task-status ${done?'done':(running?'running':'missing')}">${done?'已生成':(running?'生成中':'缺失')}</span></div>
  533. <div class="meta"><span class="pill">tween</span> 预设: ${esc(preset)}<br>${esc(t.use)}</div>
  534. ${done?'<button class="ghost play-btn">▶ 播放</button>':''}
  535. <button class="ghost retry-btn" data-kind="ui" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':(done?'重新生成':'补生成 / 重试')}</button>`;
  536. if(!done){ grid.appendChild(card); return; }
  537. const box=card.querySelector('.demo-box');
  538. card.querySelector('.play-btn').onclick=()=>{
  539. box.style.opacity=1;box.style.transform='';
  540. (TWEEN_DEMO[u.preset]||(()=>{}))(box);
  541. };
  542. grid.appendChild(card);
  543. });
  544. v.appendChild(grid);
  545. }
  546. // ---------- 事件 ----------
  547. document.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{
  548. document.querySelectorAll('.tabs button').forEach(x=>x.classList.remove('active'));
  549. b.classList.add('active'); TAB=b.dataset.tab; render();
  550. });
  551. $('#view').addEventListener('click', async e=>{
  552. const partsFromPreviewBtn=e.target.closest('.parts-from-preview-btn');
  553. if(partsFromPreviewBtn){
  554. const game=$('#gameSel').value;
  555. if(!game||game==='(暂无)'){ opMsg('没有可重生的资源库',false); return; }
  556. const item={kind:'characters',id:partsFromPreviewBtn.dataset.boss};
  557. markTasksRunning([item], true);
  558. render();
  559. opMsg(`正在按主图重生全部拆件 ${item.id}…`);
  560. const log=$('#log'); log.style.display='block'; log.textContent=`按主图重生全部拆件 ${item.id} 任务创建中…`;
  561. try{
  562. const r=await fetch('/api/retry-boss-parts-from-preview',{method:'POST',headers:{'Content-Type':'application/json'},
  563. body:JSON.stringify({
  564. game, bossId:item.id,
  565. provider:$('#provider').value, api_key:$('#apiKey').value,
  566. base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
  567. const d=await r.json();
  568. if(!d.ok || !d.jobId){
  569. log.textContent='❌ '+(d.error||'按主图重生拆件失败');
  570. markTasksRunning([item], false); render();
  571. return;
  572. }
  573. await pollJob(d.jobId, log, partsFromPreviewBtn, [item]);
  574. }catch(err){
  575. log.textContent='请求失败: '+err;
  576. markTasksRunning([item], false); render();
  577. }
  578. return;
  579. }
  580. const previewBtn=e.target.closest('.preview-btn');
  581. if(previewBtn){
  582. const game=$('#gameSel').value;
  583. if(!game||game==='(暂无)'){ opMsg('没有可重生的资源库',false); return; }
  584. const item={kind:'characters',id:previewBtn.dataset.boss};
  585. markTasksRunning([item], true);
  586. render();
  587. opMsg(`正在重生主图 ${item.id}…`);
  588. const log=$('#log'); log.style.display='block'; log.textContent=`重生主图 ${item.id} 任务创建中…`;
  589. try{
  590. const r=await fetch('/api/retry-boss-preview',{method:'POST',headers:{'Content-Type':'application/json'},
  591. body:JSON.stringify({
  592. game, bossId:item.id,
  593. provider:$('#provider').value, api_key:$('#apiKey').value,
  594. base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
  595. const d=await r.json();
  596. if(!d.ok || !d.jobId){
  597. log.textContent='❌ '+(d.error||'主图重生失败');
  598. markTasksRunning([item], false); render();
  599. return;
  600. }
  601. await pollJob(d.jobId, log, previewBtn, [item]);
  602. }catch(err){
  603. log.textContent='请求失败: '+err;
  604. markTasksRunning([item], false); render();
  605. }
  606. return;
  607. }
  608. const btn=e.target.closest('.retry-btn');
  609. if(!btn) return;
  610. const game=$('#gameSel').value;
  611. if(!game||game==='(暂无)'){ opMsg('没有可重试的资源库',false); return; }
  612. const item={kind:btn.dataset.kind,id:btn.dataset.id};
  613. markTasksRunning([item], true);
  614. render();
  615. $('#retryMissingBtn').disabled=true;
  616. opMsg(`正在补生成 ${item.kind}/${item.id}…`);
  617. const log=$('#log'); log.style.display='block'; log.textContent=`补生成 ${btn.dataset.kind}/${btn.dataset.id} 任务创建中…`;
  618. try{
  619. const r=await fetch('/api/retry-task',{method:'POST',headers:{'Content-Type':'application/json'},
  620. body:JSON.stringify({
  621. game, kind:btn.dataset.kind, id:btn.dataset.id,
  622. provider:$('#provider').value, api_key:$('#apiKey').value,
  623. base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
  624. const d=await r.json();
  625. if(!d.ok || !d.jobId){
  626. log.textContent='❌ '+(d.error||'补生成失败');
  627. markTasksRunning([item], false); render();
  628. $('#retryMissingBtn').disabled=false;
  629. return;
  630. }
  631. await pollJob(d.jobId, log, btn, [item]);
  632. }catch(err){
  633. log.textContent='请求失败: '+err;
  634. markTasksRunning([item], false); render();
  635. $('#retryMissingBtn').disabled=false;
  636. }
  637. });
  638. $('#partsClose').onclick=()=>$('#partsModal').classList.remove('open');
  639. $('#partsModal').onclick=e=>{ if(e.target.id==='partsModal') $('#partsModal').classList.remove('open'); };
  640. $('#partsRebuildAll').onclick=async()=>{
  641. const game=$('#gameSel').value;
  642. const bossId=$('#partsRebuildAll').dataset.boss;
  643. if(!game||game==='(暂无)'||!bossId){ opMsg('没有可重生的关主拆件',false); return; }
  644. const item={kind:'characters',id:bossId};
  645. $('#partsRebuildAll').disabled=true;
  646. $('#partsRebuildAll').textContent='重生中…';
  647. markTasksRunning([item], true);
  648. render();
  649. opMsg(`正在按主图重生全部拆件 ${bossId}…`);
  650. const log=$('#log'); log.style.display='block'; log.textContent=`按主图重生全部拆件 ${bossId} 任务创建中…`;
  651. try{
  652. const r=await fetch('/api/retry-boss-parts-from-preview',{method:'POST',headers:{'Content-Type':'application/json'},
  653. body:JSON.stringify({
  654. game, bossId,
  655. provider:$('#provider').value, api_key:$('#apiKey').value,
  656. base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
  657. const d=await r.json();
  658. if(!d.ok || !d.jobId){
  659. log.textContent='❌ '+(d.error||'按主图重生拆件失败');
  660. markTasksRunning([item], false); render();
  661. $('#partsRebuildAll').disabled=false;
  662. $('#partsRebuildAll').textContent='按主图重生全部拆件';
  663. return;
  664. }
  665. await pollJob(d.jobId, log, $('#partsRebuildAll'), [item]);
  666. const row=tasksFor('characters').find(t=>t.id===bossId);
  667. if(row && row.asset) openPartsModal(row.asset, row);
  668. }catch(err){
  669. log.textContent='请求失败: '+err;
  670. markTasksRunning([item], false); render();
  671. $('#partsRebuildAll').disabled=false;
  672. $('#partsRebuildAll').textContent='按主图重生全部拆件';
  673. }
  674. };
  675. $('#partsList').addEventListener('click', async e=>{
  676. const btn=e.target.closest('.part-retry-btn');
  677. if(!btn) return;
  678. const game=$('#gameSel').value;
  679. if(!game||game==='(暂无)'){ opMsg('没有可重生的资源库',false); return; }
  680. btn.disabled=true;
  681. btn.textContent='重生中…';
  682. const item={kind:'characters',id:btn.dataset.boss};
  683. markTasksRunning([item], true);
  684. render();
  685. opMsg(`正在重生拆件 ${btn.dataset.boss}/${btn.dataset.part}…`);
  686. const log=$('#log'); log.style.display='block'; log.textContent=`重生拆件 ${btn.dataset.boss}/${btn.dataset.part} 任务创建中…`;
  687. try{
  688. const r=await fetch('/api/retry-boss-part',{method:'POST',headers:{'Content-Type':'application/json'},
  689. body:JSON.stringify({
  690. game, bossId:btn.dataset.boss, partId:btn.dataset.part,
  691. provider:$('#provider').value, api_key:$('#apiKey').value,
  692. base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
  693. const d=await r.json();
  694. if(!d.ok || !d.jobId){
  695. log.textContent='❌ '+(d.error||'拆件重生失败');
  696. markTasksRunning([item], false); render();
  697. btn.disabled=false; btn.textContent='重生该拆件';
  698. return;
  699. }
  700. await pollJob(d.jobId, log, btn, [item]);
  701. const row=tasksFor('characters').find(t=>t.id===btn.dataset.boss);
  702. if(row && row.asset) openPartsModal(row.asset, row);
  703. }catch(err){
  704. log.textContent='请求失败: '+err;
  705. markTasksRunning([item], false); render();
  706. btn.disabled=false; btn.textContent='重生该拆件';
  707. }
  708. });
  709. $('#gameSel').onchange=e=>loadLibrary(e.target.value);
  710. function currentManifestGame(){
  711. try{ return JSON.parse($('#manifest').value||'{}').game || ''; }catch(e){ return ''; }
  712. }
  713. $('#reloadBtn').onclick=()=>loadLibrary($('#gameSel').value || currentManifestGame());
  714. function opMsg(t,ok=true){ const m=$('#opMsg'); m.textContent=t; m.style.color=ok?'#7ee8ff':'#ff9d9d'; }
  715. function workflowMsg(t,ok=true){ const m=$('#workflowMsg'); m.textContent=t; m.style.color=ok?'#a99ccb':'#ff9d9d'; }
  716. function renderGamePlan(plan, source){
  717. const box=$('#gamePlanView');
  718. if(!plan){ box.style.display='none'; box.textContent=''; return; }
  719. const gd=plan.gameDesign||{}, creative=plan.creative||{}, art=gd.artDirection||{};
  720. const lines=[
  721. `AI 游戏方案 · ${source||plan.source||''}`,
  722. `标题:${gd.title||''}`,
  723. `核心钩子:${gd.coreHook||''}`,
  724. `玩法:${gd.reelExperience||''} / ${gd.volatility||''}`,
  725. `差异点:${(gd.differentiators||[]).join('、')||'未生成'}`,
  726. `关主:${gd.bossDesign?JSON.stringify(gd.bossDesign):'按下方配置生成'}`,
  727. `美术主题:${art.theme||''}`,
  728. `美术风格:${art.style||''}`,
  729. `参考:${(creative.references||[]).join(',')||'无'};上传图 ${creative.uploadedReferenceImages||0} 张`,
  730. `视觉分析:${creative.visionStyleAnalysis&&Object.keys(creative.visionStyleAnalysis).length?JSON.stringify(creative.visionStyleAnalysis,null,2):(creative.visionStyleAnalysisError||'无')}`
  731. ];
  732. box.textContent=lines.join('\n');
  733. box.style.display='block';
  734. }
  735. function workflowPayload(){
  736. const features=[];
  737. if($('#wfCascades').checked) features.push('cascades');
  738. if($('#wfFreeSpins').checked) features.push('free_spins');
  739. if($('#wfWilds').checked) features.push('wilds');
  740. if($('#wfHoldWin').checked) features.push('hold_win');
  741. if($('#wfMultipliers').checked) features.push('multipliers');
  742. return {
  743. gameId: $('#wfGameId').value,
  744. title: $('#wfTitle').value,
  745. theme: $('#wfTheme').value,
  746. reelMode: $('#wfReelMode').value,
  747. volatility: $('#wfVolatility').value,
  748. targetRtp: $('#wfTargetRtp').value,
  749. enableMathModel: $('#wfEnableMathModel').checked,
  750. characterCount: $('#wfCharacterCount').value,
  751. uiCompleteness: $('#wfUiCompleteness').value,
  752. feedbackIntensity: $('#wfFeedbackIntensity').value,
  753. enableBoss: $('#wfEnableBoss').checked,
  754. bossPresence: $('#wfBossPresence').value,
  755. startingBalance: $('#wfStartingBalance').value,
  756. defaultBet: $('#wfDefaultBet').value,
  757. freeSpinCount: $('#wfFreeSpinCount').value,
  758. maxCascades: $('#wfMaxCascades').value,
  759. creative: {
  760. brief: $('#creativeBrief').value,
  761. references: $('#creativeRefs').value.split('\n').map(x=>x.trim()).filter(Boolean),
  762. uploadedReferenceImages: ($('#creativeImageFiles').files||[]).length,
  763. styleNotes: $('#creativeStyleNotes').value,
  764. avoidNotes: $('#creativeAvoidNotes').value
  765. },
  766. gameDesign: {
  767. title: $('#wfTitle').value,
  768. artDirection: { theme: $('#wfTheme').value, styleNotes: $('#creativeStyleNotes').value },
  769. reelExperience: $('#wfReelMode').value,
  770. volatility: $('#wfVolatility').value
  771. },
  772. features
  773. };
  774. }
  775. $('#buildWorkflowBtn').onclick=async()=>{
  776. const btn=$('#buildWorkflowBtn'); btn.disabled=true; workflowMsg('正在生成玩法配置和 manifest…');
  777. try{
  778. const r=await fetch('/api/slot-workflow',{method:'POST',headers:{'Content-Type':'application/json'},
  779. body:JSON.stringify(workflowPayload())});
  780. const d=await r.json();
  781. if(!d.ok){ workflowMsg('生成失败: '+(d.error||'未知错误'),false); }
  782. else{
  783. $('#manifest').value=JSON.stringify(d.manifest,null,2);
  784. await loadLibrary(d.manifest.game);
  785. const cfg=d.slot_config||d.manifest.slot_config||{};
  786. const reels=cfg.reels?`${cfg.reels.columns}×${cfg.reels.rows}`:'';
  787. const minMatch=cfg.winRules?cfg.winRules.minMatch:'';
  788. const sim=cfg.mathModel&&cfg.mathModel.simulation?cfg.mathModel.simulation:{};
  789. if(cfg.mathModel&&cfg.mathModel.status==='disabled_by_user'){
  790. workflowMsg(`已生成 ${d.manifest.game}:${reels},数学模型未生成,最小中奖 ${minMatch} 连。`);
  791. }else{
  792. workflowMsg(`已生成 ${d.manifest.game}:${reels},RTP ${((sim.estimatedRtp||0)*100).toFixed(2)}%,命中率 ${((sim.hitFrequency||0)*100).toFixed(2)}%,最小中奖 ${minMatch} 连。`);
  793. }
  794. $('#genPanel').open=true;
  795. }
  796. }catch(e){ workflowMsg('请求失败: '+e,false); }
  797. btn.disabled=false;
  798. };
  799. async function readReferenceImages(){
  800. const input=$('#creativeImageFiles');
  801. const files=Array.from(input.files||[]).slice(0,4);
  802. if(!files.length) return [];
  803. $('#creativeImageMsg').textContent=`已选择 ${files.length} 张,准备上传给视觉模型分析…`;
  804. const readers=files.map(file=>new Promise((resolve,reject)=>{
  805. if(file.size>6*1024*1024){ reject(new Error(file.name+' 超过 6MB')); return; }
  806. const r=new FileReader();
  807. r.onload=()=>resolve(r.result);
  808. r.onerror=()=>reject(r.error||new Error('读取失败'));
  809. r.readAsDataURL(file);
  810. }));
  811. return Promise.all(readers);
  812. }
  813. $('#aiWorkflowBtn').onclick=async()=>{
  814. const btn=$('#aiWorkflowBtn'); btn.disabled=true; const msg=$('#aiWorkflowMsg');
  815. msg.textContent='AI 正在理解创意和参考链接…'; msg.style.color='#a99ccb';
  816. try{
  817. const referenceImages=await readReferenceImages();
  818. if(referenceImages.length) msg.textContent='视觉模型正在分析上传参考图…';
  819. const r=await fetch('/api/creative-manifest',{method:'POST',headers:{'Content-Type':'application/json'},
  820. body:JSON.stringify({
  821. api_key:$('#apiKey').value,
  822. base_url:$('#baseUrl').value,
  823. text_model:$('#textModel').value,
  824. gameId:$('#wfGameId').value,
  825. title:$('#wfTitle').value,
  826. brief:$('#creativeBrief').value,
  827. references:$('#creativeRefs').value,
  828. reference_images:referenceImages,
  829. styleNotes:$('#creativeStyleNotes').value,
  830. avoidNotes:$('#creativeAvoidNotes').value,
  831. theme:$('#wfTheme').value,
  832. reelMode:$('#wfReelMode').value,
  833. volatility:$('#wfVolatility').value,
  834. characterCount:$('#wfCharacterCount').value,
  835. uiCompleteness:$('#wfUiCompleteness').value,
  836. feedbackIntensity:$('#wfFeedbackIntensity').value,
  837. enableBoss:$('#wfEnableBoss').checked,
  838. bossPresence:$('#wfBossPresence').value,
  839. features:workflowPayload().features,
  840. targetRtp:$('#wfTargetRtp').value,
  841. enableMathModel:$('#wfEnableMathModel').checked
  842. })});
  843. const d=await r.json();
  844. if(!d.ok){ msg.textContent='AI 生成失败: '+(d.error||'未知错误'); msg.style.color='#ff9d9d'; }
  845. else{
  846. $('#manifest').value=JSON.stringify(d.manifest,null,2);
  847. await loadLibrary(d.manifest.game);
  848. const req=d.creative_request||{};
  849. $('#wfGameId').value=req.gameId||$('#wfGameId').value;
  850. $('#wfTitle').value=req.title||$('#wfTitle').value;
  851. if(req.theme) $('#wfTheme').value=req.theme;
  852. if(req.reelMode) $('#wfReelMode').value=req.reelMode;
  853. if(req.volatility) $('#wfVolatility').value=req.volatility;
  854. if(req.characterCount) $('#wfCharacterCount').value=String(req.characterCount);
  855. if(req.uiCompleteness) $('#wfUiCompleteness').value=req.uiCompleteness;
  856. if(req.feedbackIntensity) $('#wfFeedbackIntensity').value=req.feedbackIntensity;
  857. if(typeof req.enableBoss==='boolean') $('#wfEnableBoss').checked=req.enableBoss;
  858. if(req.bossPresence) $('#wfBossPresence').value=req.bossPresence;
  859. const fs=new Set(req.features||[]);
  860. $('#wfCascades').checked=fs.has('cascades');
  861. $('#wfFreeSpins').checked=fs.has('free_spins');
  862. $('#wfWilds').checked=fs.has('wilds');
  863. $('#wfHoldWin').checked=fs.has('hold_win');
  864. $('#wfMultipliers').checked=fs.has('multipliers');
  865. renderGamePlan(d.game_plan, d.source);
  866. msg.textContent=`已生成完整游戏方案和 manifest(来源:${d.source}),可微调后开始生成图片资源。`;
  867. $('#genPanel').open=true;
  868. }
  869. }catch(e){ msg.textContent='请求失败: '+e; msg.style.color='#ff9d9d'; }
  870. btn.disabled=false;
  871. };
  872. $('#openGenBtn').onclick=()=>{ $('#genPanel').open=true; $('#genPanel').scrollIntoView({behavior:'smooth',block:'start'}); };
  873. $('#retryMissingBtn').onclick=async()=>{
  874. const game=$('#gameSel').value;
  875. if(!game||game==='(暂无)'){ opMsg('没有可补生成的资源库',false); return; }
  876. const items=missingTasks();
  877. if(!items.length){ opMsg('当前没有缺失任务'); return; }
  878. markTasksRunning(items, true);
  879. render();
  880. const btn=$('#retryMissingBtn'); btn.disabled=true;
  881. opMsg(`正在批量补生成 ${items.length} 个缺失任务…`);
  882. const log=$('#log'); log.style.display='block'; log.textContent=`批量补生成 ${items.length} 个缺失任务创建中…`;
  883. try{
  884. const r=await fetch('/api/retry-missing',{method:'POST',headers:{'Content-Type':'application/json'},
  885. body:JSON.stringify({
  886. game, provider:$('#provider').value, api_key:$('#apiKey').value,
  887. base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
  888. const d=await r.json();
  889. if(!d.ok || !d.jobId){
  890. log.textContent='❌ '+(d.error||'批量补生成失败');
  891. markTasksRunning(items, false); render();
  892. btn.disabled=false;
  893. return;
  894. }
  895. await pollJob(d.jobId, log, btn, items);
  896. }catch(e){
  897. log.textContent='请求失败: '+e;
  898. markTasksRunning(items, false); render();
  899. btn.disabled=false;
  900. }
  901. };
  902. $('#exportBtn').onclick=async()=>{
  903. const game=$('#gameSel').value;
  904. if(!game||game==='(暂无)'){ opMsg('没有可导出的资源库',false); return; }
  905. const btn=$('#exportBtn'); btn.disabled=true; opMsg('打包中…');
  906. try{
  907. const r=await fetch('/api/export',{method:'POST',headers:{'Content-Type':'application/json'},
  908. body:JSON.stringify({game})});
  909. const d=await r.json();
  910. if(d.ok) opMsg('✅ 已导出到 '+d.pack);
  911. else opMsg('❌ '+(d.error||'导出失败'),false);
  912. }catch(e){ opMsg('请求失败: '+e,false); }
  913. btn.disabled=false;
  914. };
  915. $('#openFolderBtn').onclick=async()=>{
  916. const game=$('#gameSel').value;
  917. if(!game||game==='(暂无)'){ opMsg('没有可打开的资源库',false); return; }
  918. const btn=$('#openFolderBtn'); btn.disabled=true; opMsg('正在打开素材目录…');
  919. try{
  920. const r=await fetch('/api/open-folder',{method:'POST',headers:{'Content-Type':'application/json'},
  921. body:JSON.stringify({game})});
  922. const d=await r.json();
  923. if(d.ok) opMsg('✅ 已打开 '+d.path);
  924. else opMsg('❌ '+(d.error||'打开失败'),false);
  925. }catch(e){ opMsg('请求失败: '+e,false); }
  926. btn.disabled=false;
  927. };
  928. $('#deleteBtn').onclick=async()=>{
  929. const game=$('#gameSel').value;
  930. if(!game||game==='(暂无)'){ opMsg('没有可删除的资源库',false); return; }
  931. if(!confirm(`确定删除资源库「${game}」?此操作会从磁盘删掉 out/${game} 整个文件夹,不可恢复。`)) return;
  932. const btn=$('#deleteBtn'); btn.disabled=true; opMsg('删除中…');
  933. try{
  934. const r=await fetch('/api/delete',{method:'POST',headers:{'Content-Type':'application/json'},
  935. body:JSON.stringify({game})});
  936. const d=await r.json();
  937. if(d.ok){ opMsg('✅ 已删除 '+d.deleted); await loadLibrary(); }
  938. else opMsg('❌ '+(d.error||'删除失败'),false);
  939. }catch(e){ opMsg('请求失败: '+e,false); }
  940. btn.disabled=false;
  941. };
  942. $('#startBtn').onclick=async()=>{
  943. const btn=$('#startBtn'); const log=$('#log');
  944. btn.disabled=true; log.style.display='block'; log.textContent='任务创建中…';
  945. try{
  946. const r=await fetch('/api/generate',{method:'POST',headers:{'Content-Type':'application/json'},
  947. body:JSON.stringify({
  948. provider:$('#provider').value, api_key:$('#apiKey').value,
  949. base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value,
  950. manifest:$('#manifest').value, async:true })});
  951. const d=await r.json();
  952. if(!d.ok || !d.jobId){
  953. log.textContent=(d.logs||[]).join('\n')+(d.error?('\n❌ '+d.error):'');
  954. btn.disabled=false;
  955. return;
  956. }
  957. await pollJob(d.jobId, log, btn);
  958. }catch(e){ log.textContent='请求失败: '+e; }
  959. };
  960. async function pollJob(jobId, log, btn, runningItems=[]){
  961. let lastText='';
  962. let lastRefresh=0;
  963. while(true){
  964. let d;
  965. try{
  966. d=await (await fetch('/api/job?id='+encodeURIComponent(jobId))).json();
  967. }catch(e){
  968. log.textContent=lastText+'\n轮询失败: '+e;
  969. btn.disabled=false;
  970. return;
  971. }
  972. const lines=d.logs||[];
  973. const head=`任务 ${jobId.slice(0,8)} · ${d.status||'running'} · ${(d.game||'')}`;
  974. lastText=head+'\n'+lines.join('\n')+(d.error?('\n❌ '+d.error):'');
  975. log.textContent=lastText;
  976. log.scrollTop=log.scrollHeight;
  977. if((d.logs||[]).length){
  978. opMsg((d.status==='running'?'生成中:':'任务状态:') + d.logs[d.logs.length-1], d.status!=='error');
  979. }
  980. const now=Date.now();
  981. if(d.status==='running' && d.game && now-lastRefresh>2500){
  982. lastRefresh=now;
  983. await loadLibrary(d.game, {silent:true});
  984. }
  985. if(d.status==='done'){
  986. markTasksRunning(runningItems, false);
  987. ACTIVE_TASKS.clear();
  988. await loadLibrary(d.game);
  989. if(btn) btn.disabled=false;
  990. return;
  991. }
  992. if(d.status==='error'){
  993. markTasksRunning(runningItems, false);
  994. ACTIVE_TASKS.clear();
  995. await loadLibrary(d.game || $('#gameSel').value);
  996. opMsg('任务结束但有错误,已自动刷新资源库;成功的素材已经显示。', false);
  997. if(btn) btn.disabled=false;
  998. $('#retryMissingBtn').disabled=false;
  999. return;
  1000. }
  1001. await new Promise(r=>setTimeout(r,1200));
  1002. }
  1003. }
  1004. loadManifest(); loadLibrary();
  1005. </script>
  1006. </body>
  1007. </html>