// 九宫格图生成器 - 下载处理模块 // 实现类似 Texture Packer 的功能:将多张图片拼接成一张图,并生成 JSON 文件 (function () { // 获取当前登录用户名 function getCurrentUsername() { try { const loginDataStr = localStorage.getItem('loginData'); if (!loginDataStr) { return null; } const loginData = JSON.parse(loginDataStr); const now = Date.now(); // 检查是否过期 if (now >= loginData.expireTime) { localStorage.removeItem('loginData'); return null; } return loginData.user ? loginData.user.username : null; } catch (error) { console.error('[SpriteSheet] 获取用户名失败:', error); return null; } } // 显示提示信息(使用全局 Alert 组件,直接调用) function showAlert(message, duration = 2000) { // 直接调用父窗口的 GlobalAlert(不通过 postMessage) try { // 优先使用父窗口的 GlobalAlert if (window.parent && window.parent !== window && window.parent.GlobalAlert) { window.parent.GlobalAlert.show(message, duration); return; } // 如果不在 iframe 中,直接使用当前窗口的 GlobalAlert if (window.GlobalAlert) { window.GlobalAlert.show(message, duration); return; } // 降级处理 console.log('[Alert]', message); } catch (error) { console.error('[SpriteSheet] 显示 alert 失败:', error); alert(message); } } // 统一的错误处理函数:解析服务端错误响应并显示 async function handleServerError(response, defaultMessage = '操作失败') { let errorMessage = defaultMessage; try { // 尝试解析 JSON 错误响应 const errorData = await response.json().catch(() => null); if (errorData) { // 优先使用服务端返回的 message if (errorData.message) { errorMessage = errorData.message; } else if (errorData.error) { errorMessage = errorData.error; } else if (typeof errorData === 'string') { errorMessage = errorData; } } } catch (e) { // 如果解析失败,使用默认消息或状态码 if (response.status) { errorMessage = `${defaultMessage} (状态码: ${response.status})`; } } showAlert(errorMessage); return errorMessage; } // Cocos Creator 配置选项 const COCOS_CONFIG = { // 是否使用 2 的幂次方尺寸(Cocos Creator 3.8 不需要,但可以启用以获得更好的性能) usePowerOfTwo: false, // 图片拼接时是否对齐到像素边界(推荐开启) pixelPerfect: true }; // 计算最小2的幂次方(可选,用于优化纹理内存使用) function nextPowerOfTwo(n) { if (n <= 0) return 1; if ((n & (n - 1)) === 0) return n; // 已经是2的幂 let power = 1; while (power < n) { power <<= 1; } return power; } // 计算最终尺寸(根据配置决定是否使用 2 的幂次方) function calculateFinalSize(width, height) { if (COCOS_CONFIG.usePowerOfTwo) { return { width: nextPowerOfTwo(width), height: nextPowerOfTwo(height) }; } else { // 直接使用实际尺寸,Cocos Creator 3.8 完全支持 return { width, height }; } } // 加载图片 function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); img.src = src; }); } // 显示加载动画 function showLoadingModal(folderName) { // 创建模态框 const modal = document.createElement('div'); modal.id = 'spriteSheetLoadingModal'; modal.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 99999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; `; const content = document.createElement('div'); content.style.cssText = ` background: #ffffff; border-radius: 12px; padding: 30px 40px; text-align: center; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); min-width: 300px; `; const title = document.createElement('div'); title.textContent = '正在生成 Sprite Sheet'; title.style.cssText = ` font-size: 18px; font-weight: 600; color: #1f2937; margin-bottom: 10px; `; const folder = document.createElement('div'); folder.textContent = folderName; folder.style.cssText = ` font-size: 14px; color: #6b7280; margin-bottom: 20px; `; // 加载动画 const spinner = document.createElement('div'); spinner.style.cssText = ` width: 40px; height: 40px; border: 4px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 20px; `; // 添加动画样式 if (!document.getElementById('spriteSheetLoadingStyle')) { const style = document.createElement('style'); style.id = 'spriteSheetLoadingStyle'; style.textContent = ` @keyframes spin { to { transform: rotate(360deg); } } `; document.head.appendChild(style); } const status = document.createElement('div'); status.id = 'spriteSheetStatus'; status.textContent = '加载图片中...'; status.style.cssText = ` font-size: 14px; color: #374151; `; content.appendChild(title); content.appendChild(folder); content.appendChild(spinner); content.appendChild(status); modal.appendChild(content); document.body.appendChild(modal); return { modal, updateStatus: (text) => { status.textContent = text; } }; } // 隐藏加载动画 function hideLoadingModal() { const modal = document.getElementById('spriteSheetLoadingModal'); if (modal) { modal.remove(); } } // 下载文件 function downloadFile(data, filename, mimeType) { const blob = new Blob([data], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // 生成 Cocos Creator 3.8 兼容的 JSON 格式的 sprite sheet 数据 // Cocos Creator 使用标准的 TexturePacker JSON 格式,坐标系统从上到下(左上角为原点) function generateJSON(folderName, images, sheetWidth, sheetHeight) { const frames = {}; images.forEach((img, index) => { const frameNum = (index + 1).toString().padStart(2, '0'); const frameName = `${frameNum}.png`; const x = img.x; const y = img.y; // Cocos Creator 和 Canvas 都使用从上到下的坐标系统 const width = img.width; const height = img.height; // 标准的 TexturePacker JSON 格式,Cocos Creator 3.8 完全兼容 frames[frameName] = { frame: { x: x, y: y, w: width, h: height }, rotated: false, trimmed: false, spriteSourceSize: { x: 0, y: 0, w: width, h: height }, sourceSize: { w: width, h: height } }; }); const json = { frames: frames, meta: { app: "SpriteSheetMaker for Cocos Creator 3.8", version: "1.0", image: `${folderName}.png`, format: "RGBA8888", size: { w: sheetWidth, h: sheetHeight }, scale: "1" } }; return JSON.stringify(json, null, 2); } // 将 Blob 转换为 Base64 function blobToBase64(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { // 移除 data:image/png;base64, 前缀 const base64 = reader.result.split(',')[1]; resolve(base64); }; reader.onerror = reject; reader.readAsDataURL(blob); }); } // 发送数据到服务器打包并下载 async function packAndDownload(folderName, imageBlob, jsonData, loading) { try { loading.updateStatus('转换图片数据...'); // 将图片转换为 Base64 const imageBase64 = await blobToBase64(imageBlob); loading.updateStatus('发送到服务器打包...'); // 发送到服务器打包 const response = await fetch('http://localhost:3000/api/pack', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folderName: folderName, imageData: imageBase64, jsonData: jsonData }) }); if (!response.ok) { const errorMessage = await handleServerError(response, '打包失败'); throw new Error(errorMessage); } loading.updateStatus('下载 ZIP 文件...'); // 获取 ZIP 文件的 Blob const zipBlob = await response.blob(); // 下载 ZIP 文件 downloadFile(zipBlob, `${folderName}.zip`, 'application/zip'); hideLoadingModal(); } catch (error) { hideLoadingModal(); console.error('打包失败:', error); showAlert(`打包失败: ${error.message}`); } } // 拼接图片 async function packImages(folderName, frameNumbers) { const loading = showLoadingModal(folderName); try { // 1. 加载所有图片 loading.updateStatus(`加载图片中... (0/${frameNumbers.length})`); const images = []; // 获取用户名 const username = getCurrentUsername(); if (!username) { throw new Error('请先登录'); } for (let i = 0; i < frameNumbers.length; i++) { const frameNum = frameNumbers[i]; const frameName = frameNum.toString().padStart(2, '0'); // 使用API路径,从用户目录加载 const imagePath = `${folderName}/${frameName}.png`; const imgSrc = `/api/disk/preview?username=${encodeURIComponent(username)}&path=${encodeURIComponent(imagePath)}`; loading.updateStatus(`加载图片中... (${i + 1}/${frameNumbers.length})`); const img = await loadImage(imgSrc); images.push({ img: img, width: img.width, height: img.height, frameNum: frameNum }); } // 2. 计算最优布局(尽可能接近正方形) loading.updateStatus('计算布局中...'); // 计算所有图片的平均尺寸和总面积 let totalArea = 0; let maxImageWidth = 0; let maxImageHeight = 0; images.forEach((item) => { totalArea += item.width * item.height; maxImageWidth = Math.max(maxImageWidth, item.width); maxImageHeight = Math.max(maxImageHeight, item.height); }); // 估算目标尺寸(接近正方形) const estimatedSide = Math.ceil(Math.sqrt(totalArea)); const estimatedCols = Math.ceil(estimatedSide / maxImageWidth); // 尝试不同的列数,找到长宽差最小的方案 let bestLayout = null; let bestDiff = Infinity; // 缩小搜索范围以提高效率,但确保覆盖合理的范围 let maxCols = Math.min(images.length, Math.ceil(estimatedCols * 1.5)); let minCols = Math.max(1, Math.floor(estimatedCols * 0.7)); // 确保minCols不超过maxCols,并且至少尝试几种列数 if (minCols > maxCols) { minCols = Math.max(1, Math.floor(maxCols * 0.5)); } // 对于图片数量很少的情况,确保尝试所有可能的列数 if (images.length <= 10) { minCols = 1; maxCols = images.length; } for (let cols = minCols; cols <= maxCols; cols++) { const rows = Math.ceil(images.length / cols); const layout = []; // 计算每行的宽度和高度 const rowWidths = new Array(rows).fill(0); const rowHeights = new Array(rows).fill(0); images.forEach((item, index) => { const row = Math.floor(index / cols); const col = index % cols; rowWidths[row] += item.width; rowHeights[row] = Math.max(rowHeights[row], item.height); }); // 计算总尺寸(实际尺寸) const totalWidth = Math.max(...rowWidths); const totalHeight = rowHeights.reduce((sum, h) => sum + h, 0); // 计算最终尺寸(根据配置决定是否使用 2 的幂次方) const finalSize = calculateFinalSize(totalWidth, totalHeight); const sheetWidth = finalSize.width; const sheetHeight = finalSize.height; // 计算长宽差的绝对值(使用实际尺寸比较,以便找到最接近正方形的布局) const diff = Math.abs(totalWidth - totalHeight); // 如果这个方案更好,保存它 if (diff < bestDiff) { bestDiff = diff; // 生成完整的布局位置信息 const currentLayout = []; let currentY = 0; for (let row = 0; row < rows; row++) { let currentX = 0; const rowHeight = rowHeights[row]; for (let col = 0; col < cols; col++) { const index = row * cols + col; if (index >= images.length) break; const item = images[index]; currentLayout.push({ x: currentX, y: currentY, width: item.width, height: item.height, img: item.img, frameNum: item.frameNum }); currentX += item.width; } currentY += rowHeight; } bestLayout = { layout: currentLayout, width: sheetWidth, height: sheetHeight }; } } // 如果没找到最佳布局(理论上不应该发生),使用默认的水平布局作为后备 if (!bestLayout) { let currentX = 0; let maxHeight = 0; const defaultLayout = []; images.forEach((item) => { defaultLayout.push({ x: currentX, y: 0, width: item.width, height: item.height, img: item.img, frameNum: item.frameNum }); currentX += item.width; maxHeight = Math.max(maxHeight, item.height); }); const totalWidth = currentX; const totalHeight = maxHeight; const finalSize = calculateFinalSize(totalWidth, totalHeight); bestLayout = { layout: defaultLayout, width: finalSize.width, height: finalSize.height }; } // 使用最佳布局 const layout = bestLayout.layout; const sheetWidth = bestLayout.width; const sheetHeight = bestLayout.height; // 4. 创建 Canvas 并绘制 loading.updateStatus('拼接图片中...'); const canvas = document.createElement('canvas'); canvas.width = sheetWidth; canvas.height = sheetHeight; const ctx = canvas.getContext('2d'); // 填充透明背景 ctx.clearRect(0, 0, sheetWidth, sheetHeight); // 绘制所有图片 layout.forEach((item) => { ctx.drawImage(item.img, item.x, item.y); }); // 5. 生成 JSON 文件 loading.updateStatus('生成 JSON 文件...'); const jsonData = generateJSON(folderName, layout, sheetWidth, sheetHeight); // 6. 打包并下载(使用服务器端打包) loading.updateStatus('准备打包...'); // 将图片转换为 blob,然后发送到服务器打包 canvas.toBlob(async (imageBlob) => { await packAndDownload(folderName, imageBlob, jsonData, loading); }, 'image/png'); } catch (error) { hideLoadingModal(); showAlert(`生成失败: ${error.message}`); } } // 处理卡片下载按钮点击事件 async function handleDownloadClick(folderName, index) { if (!folderName) { return; } try { // 获取帧列表 const encodedFolderName = encodeURIComponent(folderName); const response = await fetch(`http://localhost:3000/api/frames/${encodedFolderName}`); if (!response.ok) { const errorMessage = await handleServerError(response, '无法获取帧列表'); throw new Error(errorMessage); } const data = await response.json(); const frameNumbers = data.frames || []; if (frameNumbers.length === 0) { showAlert('该文件夹中没有图片'); return; } // 开始拼接 await packImages(folderName, frameNumbers); } catch (error) { showAlert(`下载失败: ${error.message}`); } } // 导出到全局 window.SpriteSheetMaker = { handleDownloadClick: handleDownloadClick }; })();