POC
This commit is contained in:
246
src/composables/useExportLayers.ts
Normal file
246
src/composables/useExportLayers.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
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<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>) => {
|
||||
const getVisibleLayers = () => layersRef.value.filter(l => l.visible);
|
||||
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
|
||||
|
||||
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
|
||||
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
||||
const vLayers = getVisibleLayers();
|
||||
vLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[cellIndex];
|
||||
if (!sprite) return;
|
||||
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 { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
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;
|
||||
|
||||
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;
|
||||
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 exportSpritesheetJSON = async () => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Nothing to export. Please add sprites first.');
|
||||
return;
|
||||
}
|
||||
|
||||
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, base64, name: sprite.file.name };
|
||||
})
|
||||
);
|
||||
return { id: layer.id, name: layer.name, visible: layer.visible, locked: layer.locked, sprites: sprites.filter(Boolean) };
|
||||
})
|
||||
);
|
||||
|
||||
const json = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersData };
|
||||
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 importSpritesheetJSON = async (jsonFile: File) => {
|
||||
const text = await jsonFile.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
const loadSprite = (spriteData: any) =>
|
||||
new Promise<Sprite>(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 });
|
||||
};
|
||||
img.src = spriteData.base64;
|
||||
});
|
||||
|
||||
if (typeof data.columns === 'number') columns.value = data.columns;
|
||||
if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled;
|
||||
|
||||
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 });
|
||||
}
|
||||
layersRef.value = newLayers;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.sprites)) {
|
||||
const sprites: Sprite[] = await Promise.all(data.sprites.map((s: any) => loadSprite(s)));
|
||||
layersRef.value = [
|
||||
{ id: crypto.randomUUID(), name: 'Base', visible: true, locked: false, sprites },
|
||||
{ id: crypto.randomUUID(), name: 'Clothes', visible: true, locked: false, sprites: [] },
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Invalid JSON format');
|
||||
};
|
||||
|
||||
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 { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
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 { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
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, name: s.file.name }))
|
||||
),
|
||||
}))
|
||||
);
|
||||
const meta = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user