This commit is contained in:
2025-11-22 02:52:36 +01:00
parent 5cc4eb8731
commit 097df1f5de
8 changed files with 726 additions and 198 deletions

View 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 };
};