338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
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>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
|
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<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, 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 };
|
|
};
|