sprite-sheet-maker.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. // 九宫格图生成器 - 下载处理模块
  2. // 实现类似 Texture Packer 的功能:将多张图片拼接成一张图,并生成 JSON 文件
  3. (function () {
  4. // 获取当前登录用户名
  5. function getCurrentUsername() {
  6. try {
  7. const loginDataStr = localStorage.getItem('loginData');
  8. if (!loginDataStr) {
  9. return null;
  10. }
  11. const loginData = JSON.parse(loginDataStr);
  12. const now = Date.now();
  13. // 检查是否过期
  14. if (now >= loginData.expireTime) {
  15. localStorage.removeItem('loginData');
  16. return null;
  17. }
  18. return loginData.user ? loginData.user.username : null;
  19. } catch (error) {
  20. console.error('[SpriteSheet] 获取用户名失败:', error);
  21. return null;
  22. }
  23. }
  24. // 显示提示信息(使用全局 Alert 组件,直接调用)
  25. function showAlert(message, duration = 2000) {
  26. // 直接调用父窗口的 GlobalAlert(不通过 postMessage)
  27. try {
  28. // 优先使用父窗口的 GlobalAlert
  29. if (window.parent && window.parent !== window && window.parent.GlobalAlert) {
  30. window.parent.GlobalAlert.show(message, duration);
  31. return;
  32. }
  33. // 如果不在 iframe 中,直接使用当前窗口的 GlobalAlert
  34. if (window.GlobalAlert) {
  35. window.GlobalAlert.show(message, duration);
  36. return;
  37. }
  38. // 降级处理
  39. console.log('[Alert]', message);
  40. } catch (error) {
  41. console.error('[SpriteSheet] 显示 alert 失败:', error);
  42. alert(message);
  43. }
  44. }
  45. // 统一的错误处理函数:解析服务端错误响应并显示
  46. async function handleServerError(response, defaultMessage = '操作失败') {
  47. let errorMessage = defaultMessage;
  48. try {
  49. // 尝试解析 JSON 错误响应
  50. const errorData = await response.json().catch(() => null);
  51. if (errorData) {
  52. // 优先使用服务端返回的 message
  53. if (errorData.message) {
  54. errorMessage = errorData.message;
  55. } else if (errorData.error) {
  56. errorMessage = errorData.error;
  57. } else if (typeof errorData === 'string') {
  58. errorMessage = errorData;
  59. }
  60. }
  61. } catch (e) {
  62. // 如果解析失败,使用默认消息或状态码
  63. if (response.status) {
  64. errorMessage = `${defaultMessage} (状态码: ${response.status})`;
  65. }
  66. }
  67. showAlert(errorMessage);
  68. return errorMessage;
  69. }
  70. // Cocos Creator 配置选项
  71. const COCOS_CONFIG = {
  72. // 是否使用 2 的幂次方尺寸(Cocos Creator 3.8 不需要,但可以启用以获得更好的性能)
  73. usePowerOfTwo: false,
  74. // 图片拼接时是否对齐到像素边界(推荐开启)
  75. pixelPerfect: true
  76. };
  77. // 计算最小2的幂次方(可选,用于优化纹理内存使用)
  78. function nextPowerOfTwo(n) {
  79. if (n <= 0) return 1;
  80. if ((n & (n - 1)) === 0) return n; // 已经是2的幂
  81. let power = 1;
  82. while (power < n) {
  83. power <<= 1;
  84. }
  85. return power;
  86. }
  87. // 计算最终尺寸(根据配置决定是否使用 2 的幂次方)
  88. function calculateFinalSize(width, height) {
  89. if (COCOS_CONFIG.usePowerOfTwo) {
  90. return {
  91. width: nextPowerOfTwo(width),
  92. height: nextPowerOfTwo(height)
  93. };
  94. } else {
  95. // 直接使用实际尺寸,Cocos Creator 3.8 完全支持
  96. return { width, height };
  97. }
  98. }
  99. // 加载图片
  100. function loadImage(src) {
  101. return new Promise((resolve, reject) => {
  102. const img = new Image();
  103. img.crossOrigin = 'anonymous';
  104. img.onload = () => resolve(img);
  105. img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
  106. img.src = src;
  107. });
  108. }
  109. // 显示加载动画
  110. function showLoadingModal(folderName) {
  111. // 创建模态框
  112. const modal = document.createElement('div');
  113. modal.id = 'spriteSheetLoadingModal';
  114. modal.style.cssText = `
  115. position: fixed;
  116. top: 0;
  117. left: 0;
  118. width: 100%;
  119. height: 100%;
  120. background: rgba(0, 0, 0, 0.7);
  121. display: flex;
  122. flex-direction: column;
  123. align-items: center;
  124. justify-content: center;
  125. z-index: 99999;
  126. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  127. `;
  128. const content = document.createElement('div');
  129. content.style.cssText = `
  130. background: #ffffff;
  131. border-radius: 12px;
  132. padding: 30px 40px;
  133. text-align: center;
  134. box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
  135. min-width: 300px;
  136. `;
  137. const title = document.createElement('div');
  138. title.textContent = '正在生成 Sprite Sheet';
  139. title.style.cssText = `
  140. font-size: 18px;
  141. font-weight: 600;
  142. color: #1f2937;
  143. margin-bottom: 10px;
  144. `;
  145. const folder = document.createElement('div');
  146. folder.textContent = folderName;
  147. folder.style.cssText = `
  148. font-size: 14px;
  149. color: #6b7280;
  150. margin-bottom: 20px;
  151. `;
  152. // 加载动画
  153. const spinner = document.createElement('div');
  154. spinner.style.cssText = `
  155. width: 40px;
  156. height: 40px;
  157. border: 4px solid #e5e7eb;
  158. border-top-color: #3b82f6;
  159. border-radius: 50%;
  160. animation: spin 0.8s linear infinite;
  161. margin: 0 auto 20px;
  162. `;
  163. // 添加动画样式
  164. if (!document.getElementById('spriteSheetLoadingStyle')) {
  165. const style = document.createElement('style');
  166. style.id = 'spriteSheetLoadingStyle';
  167. style.textContent = `
  168. @keyframes spin {
  169. to { transform: rotate(360deg); }
  170. }
  171. `;
  172. document.head.appendChild(style);
  173. }
  174. const status = document.createElement('div');
  175. status.id = 'spriteSheetStatus';
  176. status.textContent = '加载图片中...';
  177. status.style.cssText = `
  178. font-size: 14px;
  179. color: #374151;
  180. `;
  181. content.appendChild(title);
  182. content.appendChild(folder);
  183. content.appendChild(spinner);
  184. content.appendChild(status);
  185. modal.appendChild(content);
  186. document.body.appendChild(modal);
  187. return {
  188. modal,
  189. updateStatus: (text) => {
  190. status.textContent = text;
  191. }
  192. };
  193. }
  194. // 隐藏加载动画
  195. function hideLoadingModal() {
  196. const modal = document.getElementById('spriteSheetLoadingModal');
  197. if (modal) {
  198. modal.remove();
  199. }
  200. }
  201. // 下载文件
  202. function downloadFile(data, filename, mimeType) {
  203. const blob = new Blob([data], { type: mimeType });
  204. const url = URL.createObjectURL(blob);
  205. const a = document.createElement('a');
  206. a.href = url;
  207. a.download = filename;
  208. document.body.appendChild(a);
  209. a.click();
  210. document.body.removeChild(a);
  211. URL.revokeObjectURL(url);
  212. }
  213. // 生成 Cocos Creator 3.8 兼容的 JSON 格式的 sprite sheet 数据
  214. // Cocos Creator 使用标准的 TexturePacker JSON 格式,坐标系统从上到下(左上角为原点)
  215. function generateJSON(folderName, images, sheetWidth, sheetHeight) {
  216. const frames = {};
  217. images.forEach((img, index) => {
  218. const frameNum = (index + 1).toString().padStart(2, '0');
  219. const frameName = `${frameNum}.png`;
  220. const x = img.x;
  221. const y = img.y; // Cocos Creator 和 Canvas 都使用从上到下的坐标系统
  222. const width = img.width;
  223. const height = img.height;
  224. // 标准的 TexturePacker JSON 格式,Cocos Creator 3.8 完全兼容
  225. frames[frameName] = {
  226. frame: {
  227. x: x,
  228. y: y,
  229. w: width,
  230. h: height
  231. },
  232. rotated: false,
  233. trimmed: false,
  234. spriteSourceSize: { x: 0, y: 0, w: width, h: height },
  235. sourceSize: { w: width, h: height }
  236. };
  237. });
  238. const json = {
  239. frames: frames,
  240. meta: {
  241. app: "SpriteSheetMaker for Cocos Creator 3.8",
  242. version: "1.0",
  243. image: `${folderName}.png`,
  244. format: "RGBA8888",
  245. size: { w: sheetWidth, h: sheetHeight },
  246. scale: "1"
  247. }
  248. };
  249. return JSON.stringify(json, null, 2);
  250. }
  251. // 将 Blob 转换为 Base64
  252. function blobToBase64(blob) {
  253. return new Promise((resolve, reject) => {
  254. const reader = new FileReader();
  255. reader.onloadend = () => {
  256. // 移除 data:image/png;base64, 前缀
  257. const base64 = reader.result.split(',')[1];
  258. resolve(base64);
  259. };
  260. reader.onerror = reject;
  261. reader.readAsDataURL(blob);
  262. });
  263. }
  264. // 发送数据到服务器打包并下载
  265. async function packAndDownload(folderName, imageBlob, jsonData, loading) {
  266. try {
  267. loading.updateStatus('转换图片数据...');
  268. // 将图片转换为 Base64
  269. const imageBase64 = await blobToBase64(imageBlob);
  270. loading.updateStatus('发送到服务器打包...');
  271. // 发送到服务器打包
  272. const response = await fetch('http://localhost:3000/api/pack', {
  273. method: 'POST',
  274. headers: {
  275. 'Content-Type': 'application/json'
  276. },
  277. body: JSON.stringify({
  278. folderName: folderName,
  279. imageData: imageBase64,
  280. jsonData: jsonData
  281. })
  282. });
  283. if (!response.ok) {
  284. const errorMessage = await handleServerError(response, '打包失败');
  285. throw new Error(errorMessage);
  286. }
  287. loading.updateStatus('下载 ZIP 文件...');
  288. // 获取 ZIP 文件的 Blob
  289. const zipBlob = await response.blob();
  290. // 下载 ZIP 文件
  291. downloadFile(zipBlob, `${folderName}.zip`, 'application/zip');
  292. hideLoadingModal();
  293. } catch (error) {
  294. hideLoadingModal();
  295. console.error('打包失败:', error);
  296. showAlert(`打包失败: ${error.message}`);
  297. }
  298. }
  299. // 拼接图片
  300. async function packImages(folderName, frameNumbers) {
  301. const loading = showLoadingModal(folderName);
  302. try {
  303. // 1. 加载所有图片
  304. loading.updateStatus(`加载图片中... (0/${frameNumbers.length})`);
  305. const images = [];
  306. // 获取用户名
  307. const username = getCurrentUsername();
  308. if (!username) {
  309. throw new Error('请先登录');
  310. }
  311. for (let i = 0; i < frameNumbers.length; i++) {
  312. const frameNum = frameNumbers[i];
  313. const frameName = frameNum.toString().padStart(2, '0');
  314. // 使用API路径,从用户目录加载
  315. const imagePath = `${folderName}/${frameName}.png`;
  316. const imgSrc = `/api/disk/preview?username=${encodeURIComponent(username)}&path=${encodeURIComponent(imagePath)}`;
  317. loading.updateStatus(`加载图片中... (${i + 1}/${frameNumbers.length})`);
  318. const img = await loadImage(imgSrc);
  319. images.push({
  320. img: img,
  321. width: img.width,
  322. height: img.height,
  323. frameNum: frameNum
  324. });
  325. }
  326. // 2. 计算最优布局(尽可能接近正方形)
  327. loading.updateStatus('计算布局中...');
  328. // 计算所有图片的平均尺寸和总面积
  329. let totalArea = 0;
  330. let maxImageWidth = 0;
  331. let maxImageHeight = 0;
  332. images.forEach((item) => {
  333. totalArea += item.width * item.height;
  334. maxImageWidth = Math.max(maxImageWidth, item.width);
  335. maxImageHeight = Math.max(maxImageHeight, item.height);
  336. });
  337. // 估算目标尺寸(接近正方形)
  338. const estimatedSide = Math.ceil(Math.sqrt(totalArea));
  339. const estimatedCols = Math.ceil(estimatedSide / maxImageWidth);
  340. // 尝试不同的列数,找到长宽差最小的方案
  341. let bestLayout = null;
  342. let bestDiff = Infinity;
  343. // 缩小搜索范围以提高效率,但确保覆盖合理的范围
  344. let maxCols = Math.min(images.length, Math.ceil(estimatedCols * 1.5));
  345. let minCols = Math.max(1, Math.floor(estimatedCols * 0.7));
  346. // 确保minCols不超过maxCols,并且至少尝试几种列数
  347. if (minCols > maxCols) {
  348. minCols = Math.max(1, Math.floor(maxCols * 0.5));
  349. }
  350. // 对于图片数量很少的情况,确保尝试所有可能的列数
  351. if (images.length <= 10) {
  352. minCols = 1;
  353. maxCols = images.length;
  354. }
  355. for (let cols = minCols; cols <= maxCols; cols++) {
  356. const rows = Math.ceil(images.length / cols);
  357. const layout = [];
  358. // 计算每行的宽度和高度
  359. const rowWidths = new Array(rows).fill(0);
  360. const rowHeights = new Array(rows).fill(0);
  361. images.forEach((item, index) => {
  362. const row = Math.floor(index / cols);
  363. const col = index % cols;
  364. rowWidths[row] += item.width;
  365. rowHeights[row] = Math.max(rowHeights[row], item.height);
  366. });
  367. // 计算总尺寸(实际尺寸)
  368. const totalWidth = Math.max(...rowWidths);
  369. const totalHeight = rowHeights.reduce((sum, h) => sum + h, 0);
  370. // 计算最终尺寸(根据配置决定是否使用 2 的幂次方)
  371. const finalSize = calculateFinalSize(totalWidth, totalHeight);
  372. const sheetWidth = finalSize.width;
  373. const sheetHeight = finalSize.height;
  374. // 计算长宽差的绝对值(使用实际尺寸比较,以便找到最接近正方形的布局)
  375. const diff = Math.abs(totalWidth - totalHeight);
  376. // 如果这个方案更好,保存它
  377. if (diff < bestDiff) {
  378. bestDiff = diff;
  379. // 生成完整的布局位置信息
  380. const currentLayout = [];
  381. let currentY = 0;
  382. for (let row = 0; row < rows; row++) {
  383. let currentX = 0;
  384. const rowHeight = rowHeights[row];
  385. for (let col = 0; col < cols; col++) {
  386. const index = row * cols + col;
  387. if (index >= images.length) break;
  388. const item = images[index];
  389. currentLayout.push({
  390. x: currentX,
  391. y: currentY,
  392. width: item.width,
  393. height: item.height,
  394. img: item.img,
  395. frameNum: item.frameNum
  396. });
  397. currentX += item.width;
  398. }
  399. currentY += rowHeight;
  400. }
  401. bestLayout = {
  402. layout: currentLayout,
  403. width: sheetWidth,
  404. height: sheetHeight
  405. };
  406. }
  407. }
  408. // 如果没找到最佳布局(理论上不应该发生),使用默认的水平布局作为后备
  409. if (!bestLayout) {
  410. let currentX = 0;
  411. let maxHeight = 0;
  412. const defaultLayout = [];
  413. images.forEach((item) => {
  414. defaultLayout.push({
  415. x: currentX,
  416. y: 0,
  417. width: item.width,
  418. height: item.height,
  419. img: item.img,
  420. frameNum: item.frameNum
  421. });
  422. currentX += item.width;
  423. maxHeight = Math.max(maxHeight, item.height);
  424. });
  425. const totalWidth = currentX;
  426. const totalHeight = maxHeight;
  427. const finalSize = calculateFinalSize(totalWidth, totalHeight);
  428. bestLayout = {
  429. layout: defaultLayout,
  430. width: finalSize.width,
  431. height: finalSize.height
  432. };
  433. }
  434. // 使用最佳布局
  435. const layout = bestLayout.layout;
  436. const sheetWidth = bestLayout.width;
  437. const sheetHeight = bestLayout.height;
  438. // 4. 创建 Canvas 并绘制
  439. loading.updateStatus('拼接图片中...');
  440. const canvas = document.createElement('canvas');
  441. canvas.width = sheetWidth;
  442. canvas.height = sheetHeight;
  443. const ctx = canvas.getContext('2d');
  444. // 填充透明背景
  445. ctx.clearRect(0, 0, sheetWidth, sheetHeight);
  446. // 绘制所有图片
  447. layout.forEach((item) => {
  448. ctx.drawImage(item.img, item.x, item.y);
  449. });
  450. // 5. 生成 JSON 文件
  451. loading.updateStatus('生成 JSON 文件...');
  452. const jsonData = generateJSON(folderName, layout, sheetWidth, sheetHeight);
  453. // 6. 打包并下载(使用服务器端打包)
  454. loading.updateStatus('准备打包...');
  455. // 将图片转换为 blob,然后发送到服务器打包
  456. canvas.toBlob(async (imageBlob) => {
  457. await packAndDownload(folderName, imageBlob, jsonData, loading);
  458. }, 'image/png');
  459. } catch (error) {
  460. hideLoadingModal();
  461. showAlert(`生成失败: ${error.message}`);
  462. }
  463. }
  464. // 处理卡片下载按钮点击事件
  465. async function handleDownloadClick(folderName, index) {
  466. if (!folderName) {
  467. return;
  468. }
  469. try {
  470. // 获取帧列表
  471. const encodedFolderName = encodeURIComponent(folderName);
  472. const response = await fetch(`http://localhost:3000/api/frames/${encodedFolderName}`);
  473. if (!response.ok) {
  474. const errorMessage = await handleServerError(response, '无法获取帧列表');
  475. throw new Error(errorMessage);
  476. }
  477. const data = await response.json();
  478. const frameNumbers = data.frames || [];
  479. if (frameNumbers.length === 0) {
  480. showAlert('该文件夹中没有图片');
  481. return;
  482. }
  483. // 开始拼接
  484. await packImages(folderName, frameNumbers);
  485. } catch (error) {
  486. showAlert(`下载失败: ${error.message}`);
  487. }
  488. }
  489. // 导出到全局
  490. window.SpriteSheetMaker = {
  491. handleDownloadClick: handleDownloadClick
  492. };
  493. })();