import type { Ref } from 'vue'; import GIF from 'gif.js'; import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url'; import JSZip from 'jszip'; import type { Layer, Sprite } from '@/types/sprites'; import { getMaxDimensionsAcrossLayers } from './useLayers'; import { calculateNegativeSpacing } from './useNegativeSpacing'; export const useExportLayers = (layersRef: Ref, columns: Ref, negativeSpacingEnabled: Ref, activeLayerId?: Ref, backgroundColor?: Ref, manualCellSizeEnabled?: Ref, manualCellWidth?: Ref, manualCellHeight?: Ref) => { const getVisibleLayers = () => layersRef.value.filter(l => l.visible); const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites); const getCellDimensions = () => { if (manualCellSizeEnabled?.value) { return { cellWidth: manualCellWidth?.value ?? 64, cellHeight: manualCellHeight?.value ?? 64, negativeSpacing: 0, }; } const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(layersRef.value); const allSprites = layersRef.value.flatMap(l => l.sprites); const negativeSpacing = calculateNegativeSpacing(allSprites, negativeSpacingEnabled.value); return { cellWidth: maxWidth + negativeSpacing, cellHeight: maxHeight + negativeSpacing, negativeSpacing, }; }; const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => { ctx.clearRect(0, 0, cellWidth, cellHeight); if (backgroundColor?.value && backgroundColor.value !== 'transparent') { ctx.fillStyle = backgroundColor.value; ctx.fillRect(0, 0, cellWidth, cellHeight); } const vLayers = getVisibleLayers(); vLayers.forEach(layer => { const sprite = layer.sprites[cellIndex]; if (!sprite) return; if (sprite.rotation || sprite.flipX || sprite.flipY) { ctx.save(); const centerX = Math.floor(negativeSpacing + sprite.x + sprite.width / 2); const centerY = Math.floor(negativeSpacing + sprite.y + sprite.height / 2); ctx.translate(centerX, centerY); ctx.rotate((sprite.rotation * Math.PI) / 180); ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1); ctx.drawImage(sprite.img, -sprite.width / 2, -sprite.height / 2); ctx.restore(); } else { ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y)); } }); }; const downloadSpritesheet = () => { const visibleLayers = getVisibleLayers(); if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) { alert('Please upload or import sprites before downloading the spritesheet.'); return; } const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions(); const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length)); const rows = Math.ceil(maxLen / columns.value); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return; canvas.width = cellWidth * columns.value; canvas.height = cellHeight * rows; ctx.imageSmoothingEnabled = false; if (backgroundColor?.value && backgroundColor.value !== 'transparent') { ctx.fillStyle = backgroundColor.value; ctx.fillRect(0, 0, canvas.width, canvas.height); } for (let index = 0; index < maxLen; index++) { const col = index % columns.value; const row = Math.floor(index / columns.value); const cellX = Math.floor(col * cellWidth); const cellY = Math.floor(row * cellHeight); const cellCanvas = document.createElement('canvas'); const cellCtx = cellCanvas.getContext('2d'); if (!cellCtx) return; cellCanvas.width = cellWidth; cellCanvas.height = cellHeight; cellCtx.imageSmoothingEnabled = false; drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing); ctx.drawImage(cellCanvas, cellX, cellY); } const link = document.createElement('a'); link.download = 'spritesheet.png'; link.href = canvas.toDataURL('image/png', 1.0); link.click(); }; const generateProjectJSON = async () => { const layersData = await Promise.all( layersRef.value.map(async layer => { const sprites = await Promise.all( layer.sprites.map(async sprite => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return null; canvas.width = sprite.width; canvas.height = sprite.height; ctx.drawImage(sprite.img, 0, 0); const base64 = canvas.toDataURL('image/png'); return { id: sprite.id, width: sprite.width, height: sprite.height, x: sprite.x, y: sprite.y, rotation: sprite.rotation, flipX: sprite.flipX, flipY: sprite.flipY, base64, name: sprite.file.name }; }) ); return { id: layer.id, name: layer.name, visible: layer.visible, locked: layer.locked, sprites: sprites.filter(Boolean) }; }) ); return { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, backgroundColor: backgroundColor?.value || 'transparent', manualCellSizeEnabled: manualCellSizeEnabled?.value || false, manualCellWidth: manualCellWidth?.value || 64, manualCellHeight: manualCellHeight?.value || 64, layers: layersData, }; }; const exportSpritesheetJSON = async () => { const visibleLayers = getVisibleLayers(); if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) { alert('Nothing to export. Please add sprites first.'); return; } const json = await generateProjectJSON(); const jsonString = JSON.stringify(json, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'spritesheet.json'; a.click(); URL.revokeObjectURL(url); }; const loadProjectData = async (data: any) => { const loadSprite = (spriteData: any) => new Promise(resolve => { const img = new Image(); img.onload = () => { const byteString = atob(spriteData.base64.split(',')[1]); const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0]; const ab = new ArrayBuffer(byteString.length); const ia = new Uint8Array(ab); for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i); const blob = new Blob([ab], { type: mimeType }); const fileName = spriteData.name || `sprite-${spriteData.id}.png`; const file = new File([blob], fileName, { type: mimeType }); resolve({ id: spriteData.id || crypto.randomUUID(), file, img, url: spriteData.base64, width: spriteData.width, height: spriteData.height, x: spriteData.x || 0, y: spriteData.y || 0, rotation: spriteData.rotation || 0, flipX: spriteData.flipX || false, flipY: spriteData.flipY || false }); }; img.onerror = () => { console.error('Failed to load sprite image:', spriteData.name); // Create a 1x1 transparent placeholder or similar to avoid breaking the entire project load // For now, we'll just resolve with a "broken" sprite but maybe with 0 width/height effectively // or we could construct a dummy file. // Let's resolve with a valid but empty/placeholder structure to let other sprites load. resolve({ id: spriteData.id || crypto.randomUUID(), file: new File([], 'broken-image'), img: new Image(), // Empty image url: '', width: 0, height: 0, x: spriteData.x || 0, y: spriteData.y || 0, rotation: 0, flipX: false, flipY: false, }); }; img.src = spriteData.base64; }); if (typeof data.columns === 'number') columns.value = data.columns; if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled; if (typeof data.backgroundColor === 'string' && backgroundColor) backgroundColor.value = data.backgroundColor; if (typeof data.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = data.manualCellSizeEnabled; if (typeof data.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = data.manualCellWidth; if (typeof data.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = data.manualCellHeight; if (Array.isArray(data.layers)) { const newLayers: Layer[] = []; for (const layerData of data.layers) { const sprites: Sprite[] = await Promise.all(layerData.sprites.map((s: any) => loadSprite(s))); newLayers.push({ id: layerData.id || crypto.randomUUID(), name: layerData.name || 'Layer', visible: layerData.visible !== false, locked: !!layerData.locked, sprites }); } if (newLayers.length > 0 && !newLayers.some(l => l.visible && l.sprites.length > 0)) { const firstLayerWithSprites = newLayers.find(l => l.sprites.length > 0); if (firstLayerWithSprites) { firstLayerWithSprites.visible = true; } } layersRef.value = newLayers; if (activeLayerId && newLayers.length > 0) { const firstWithSprites = newLayers.find(l => l.sprites.length > 0); activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id; } return; } if (Array.isArray(data.sprites)) { const sprites: Sprite[] = await Promise.all(data.sprites.map((s: any) => loadSprite(s))); const baseLayerId = crypto.randomUUID(); layersRef.value = [ { id: baseLayerId, name: 'Base', visible: true, locked: false, sprites }, { id: crypto.randomUUID(), name: 'Other', visible: true, locked: false, sprites: [] }, ]; if (activeLayerId) { activeLayerId.value = baseLayerId; } return; } throw new Error('Invalid JSON format'); }; const importSpritesheetJSON = async (jsonFile: File) => { const text = await jsonFile.text(); const data = JSON.parse(text); await loadProjectData(data); }; const downloadAsGif = (fps: number) => { const visibleLayers = getVisibleLayers(); if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) { alert('Please upload or import sprites before generating a GIF.'); return; } const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions(); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return; canvas.width = cellWidth; canvas.height = cellHeight; ctx.imageSmoothingEnabled = false; const gif = new GIF({ workers: 2, quality: 10, width: cellWidth, height: cellHeight, workerScript: gifWorkerUrl }); const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length)); for (let i = 0; i < maxLen; i++) { drawCompositeCell(ctx, i, cellWidth, cellHeight, negativeSpacing); gif.addFrame(ctx, { copy: true, delay: 1000 / fps }); } gif.on('finished', (blob: Blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'animation.gif'; a.click(); URL.revokeObjectURL(url); }); gif.render(); }; const downloadAsZip = async () => { const visibleLayers = getVisibleLayers(); if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) { alert('Please upload or import sprites before downloading a ZIP.'); return; } const zip = new JSZip(); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions(); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return; canvas.width = cellWidth; canvas.height = cellHeight; ctx.imageSmoothingEnabled = false; const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length)); for (let i = 0; i < maxLen; i++) { drawCompositeCell(ctx, i, cellWidth, cellHeight, negativeSpacing); const dataURL = canvas.toDataURL('image/png'); const binary = atob(dataURL.split(',')[1]); const buf = new ArrayBuffer(binary.length); const view = new Uint8Array(buf); for (let j = 0; j < binary.length; j++) view[j] = binary.charCodeAt(j); zip.file(`frames/frame_${String(i + 1).padStart(3, '0')}.png`, view); } const jsonFolder = zip.folder('export')!; const jsonBlobPromise = (async () => { const layersPayload = await Promise.all( layersRef.value.map(async layer => ({ id: layer.id, name: layer.name, visible: layer.visible, locked: layer.locked, sprites: await Promise.all(layer.sprites.map(async s => ({ id: s.id, width: s.width, height: s.height, x: s.x, y: s.y, rotation: s.rotation, flipX: s.flipX, flipY: s.flipY, name: s.file.name }))), })) ); const meta = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, backgroundColor: backgroundColor?.value || 'transparent', manualCellSizeEnabled: manualCellSizeEnabled?.value || false, manualCellWidth: manualCellWidth?.value || 64, manualCellHeight: manualCellHeight?.value || 64, layers: layersPayload, }; const metaStr = JSON.stringify(meta, null, 2); jsonFolder.file('spritesheet.meta.json', metaStr); })(); await jsonBlobPromise; const content = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(content); const a = document.createElement('a'); a.href = url; a.download = 'sprites.zip'; a.click(); URL.revokeObjectURL(url); }; return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip, generateProjectJSON, loadProjectData }; };