index.html 55 KB

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