504 lines
18 KiB
Vue
504 lines
18 KiB
Vue
<template>
|
|
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 p-6">
|
|
<div class="max-w-6xl mx-auto">
|
|
<h1 class="text-4xl font-bold text-center mb-8 text-gray-900 tracking-tight">Spritesheet generator</h1>
|
|
<div class="flex justify-center space-x-4 mb-8">
|
|
<a href="https://gitea.directonline.io/noxious/spritesheet-generator" target="_blank" class="text-blue-500 hover:text-blue-600 transition-colors"> <i class="fab fa-github"></i> Source </a>
|
|
<a href="https://discord.gg/JTev3nzeDa" target="_blank" class="text-blue-500 hover:text-blue-600 transition-colors"> <i class="fab fa-discord"></i> Discord </a>
|
|
<a href="#" @click.prevent="openHelpModal" class="text-blue-500 hover:text-blue-600 transition-colors"> <i class="fas fa-question-circle"></i> Help </a>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-2xl shadow-soft p-8">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-xl font-semibold text-gray-800">Upload sprites</h2>
|
|
<button @click="openJSONImportDialog" class="px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white font-medium rounded-lg transition-colors flex items-center space-x-2">
|
|
<i class="fas fa-file-import"></i>
|
|
<span>Import JSON</span>
|
|
</button>
|
|
</div>
|
|
<file-uploader @upload-sprites="handleSpritesUpload" />
|
|
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
|
|
|
<div v-if="sprites.length > 0" class="mt-8">
|
|
<div class="flex flex-wrap items-center gap-6 mb-8">
|
|
<div class="flex items-center space-x-1">
|
|
<label for="columns" class="text-gray-700 font-medium">Columns:</label>
|
|
<input id="columns" type="number" v-model="columns" min="1" max="10" class="w-20 px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all" />
|
|
</div>
|
|
|
|
<!-- Add mass position buttons -->
|
|
<div class="flex items-center space-x-2">
|
|
<button @click="alignSprites('left')" class="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" title="Align Left">
|
|
<i class="fas fa-arrow-left"></i>
|
|
</button>
|
|
<button @click="alignSprites('center')" class="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" title="Align Center">
|
|
<i class="fas fa-arrows-left-right"></i>
|
|
</button>
|
|
<button @click="alignSprites('right')" class="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" title="Align Right">
|
|
<i class="fas fa-arrow-right"></i>
|
|
</button>
|
|
<button @click="alignSprites('top')" class="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" title="Align Top">
|
|
<i class="fas fa-arrow-up"></i>
|
|
</button>
|
|
<button @click="alignSprites('middle')" class="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" title="Align Middle">
|
|
<i class="fas fa-arrows-up-down"></i>
|
|
</button>
|
|
<button @click="alignSprites('bottom')" class="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" title="Align Bottom">
|
|
<i class="fas fa-arrow-down"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<button @click="downloadSpritesheet" class="px-6 py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors flex items-center space-x-2">
|
|
<i class="fas fa-download"></i>
|
|
<span>Download spritesheet</span>
|
|
</button>
|
|
|
|
<button @click="exportSpritesheetJSON" class="px-6 py-2.5 bg-purple-500 hover:bg-purple-600 text-white font-medium rounded-lg transition-colors flex items-center space-x-2">
|
|
<i class="fas fa-file-code"></i>
|
|
<span>Export as JSON</span>
|
|
</button>
|
|
|
|
<button @click="openPreviewModal" class="px-6 py-2.5 bg-green-500 hover:bg-green-600 text-white font-medium rounded-lg transition-colors flex items-center space-x-2">
|
|
<i class="fas fa-play"></i>
|
|
<span>Preview animation</span>
|
|
</button>
|
|
</div>
|
|
|
|
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
|
<sprite-preview :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
|
</Modal>
|
|
|
|
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
|
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue';
|
|
import FileUploader from './components/FileUploader.vue';
|
|
import SpriteCanvas from './components/SpriteCanvas.vue';
|
|
import Modal from './components/utilities/Modal.vue';
|
|
import SpritePreview from './components/SpritePreview.vue';
|
|
import HelpModal from './components/HelpModal.vue';
|
|
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
|
|
|
interface Sprite {
|
|
id: string;
|
|
file: File;
|
|
img: HTMLImageElement;
|
|
url: string;
|
|
width: number;
|
|
height: number;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
interface SpriteFile {
|
|
file: File;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const sprites = ref<Sprite[]>([]);
|
|
const columns = ref(4);
|
|
const isPreviewModalOpen = ref(false);
|
|
const isHelpModalOpen = ref(false);
|
|
const isSpritesheetSplitterOpen = ref(false);
|
|
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
|
const spritesheetImageUrl = ref('');
|
|
const spritesheetImageFile = ref<File | null>(null);
|
|
|
|
const handleSpritesUpload = (files: File[]) => {
|
|
// Check if any of the files is a JSON file
|
|
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
|
|
|
if (jsonFile) {
|
|
// If it's a JSON file, try to import it
|
|
importSpritesheetJSON(jsonFile);
|
|
return;
|
|
}
|
|
|
|
// Check if it's a single image file that might be a spritesheet
|
|
if (files.length === 1 && files[0].type.startsWith('image/')) {
|
|
const file = files[0];
|
|
const url = URL.createObjectURL(file);
|
|
|
|
// Load the image to check its dimensions
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
// Ask the user if they want to split the spritesheet
|
|
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
|
|
// Open the spritesheet splitter
|
|
spritesheetImageUrl.value = url;
|
|
spritesheetImageFile.value = file;
|
|
isSpritesheetSplitterOpen.value = true;
|
|
return;
|
|
}
|
|
|
|
// If the user doesn't want to split or it's not large enough, process as a single sprite
|
|
processImageFiles([file]);
|
|
};
|
|
img.src = url;
|
|
return;
|
|
}
|
|
|
|
// Process multiple image files normally
|
|
processImageFiles(files);
|
|
};
|
|
|
|
// Extract the image processing logic to a separate function for reuse
|
|
const processImageFiles = (files: File[]) => {
|
|
Promise.all(
|
|
files.map(file => {
|
|
return new Promise<Sprite>(resolve => {
|
|
const url = URL.createObjectURL(file);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
resolve({
|
|
id: crypto.randomUUID(),
|
|
file,
|
|
img,
|
|
url,
|
|
width: img.width,
|
|
height: img.height,
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
};
|
|
img.src = url;
|
|
});
|
|
})
|
|
).then(newSprites => {
|
|
sprites.value = [...sprites.value, ...newSprites];
|
|
});
|
|
};
|
|
|
|
const updateSpritePosition = (id: string, x: number, y: number) => {
|
|
const spriteIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
|
if (spriteIndex !== -1) {
|
|
// Ensure integer positions for pixel-perfect rendering
|
|
sprites.value[spriteIndex].x = Math.floor(x);
|
|
sprites.value[spriteIndex].y = Math.floor(y);
|
|
}
|
|
};
|
|
|
|
const downloadSpritesheet = () => {
|
|
if (sprites.value.length === 0) return;
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Find max dimensions
|
|
let maxWidth = 0;
|
|
let maxHeight = 0;
|
|
|
|
sprites.value.forEach(sprite => {
|
|
if (sprite.width > maxWidth) maxWidth = sprite.width;
|
|
if (sprite.height > maxHeight) maxHeight = sprite.height;
|
|
});
|
|
|
|
// Set canvas size
|
|
const rows = Math.ceil(sprites.value.length / columns.value);
|
|
canvas.width = maxWidth * columns.value;
|
|
canvas.height = maxHeight * rows;
|
|
|
|
// Disable image smoothing for pixel-perfect rendering
|
|
ctx.imageSmoothingEnabled = false;
|
|
|
|
// Draw sprites with integer positions
|
|
sprites.value.forEach((sprite, index) => {
|
|
const col = index % columns.value;
|
|
const row = Math.floor(index / columns.value);
|
|
|
|
const cellX = Math.floor(col * maxWidth);
|
|
const cellY = Math.floor(row * maxHeight);
|
|
|
|
ctx.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
|
});
|
|
|
|
// Create download link with PNG format
|
|
const link = document.createElement('a');
|
|
link.download = 'spritesheet.png';
|
|
link.href = canvas.toDataURL('image/png', 1.0); // Use maximum quality
|
|
link.click();
|
|
};
|
|
|
|
// Preview modal control
|
|
const openPreviewModal = () => {
|
|
console.log('Opening preview modal');
|
|
if (sprites.value.length === 0) {
|
|
console.log('No sprites to preview');
|
|
return;
|
|
}
|
|
isPreviewModalOpen.value = true;
|
|
};
|
|
|
|
const closePreviewModal = () => {
|
|
isPreviewModalOpen.value = false;
|
|
};
|
|
|
|
// Help modal control
|
|
const openHelpModal = () => {
|
|
isHelpModalOpen.value = true;
|
|
};
|
|
|
|
const closeHelpModal = () => {
|
|
isHelpModalOpen.value = false;
|
|
};
|
|
|
|
// Spritesheet splitter modal control
|
|
const closeSpritesheetSplitter = () => {
|
|
isSpritesheetSplitterOpen.value = false;
|
|
// Clean up the URL object to prevent memory leaks
|
|
if (spritesheetImageUrl.value) {
|
|
URL.revokeObjectURL(spritesheetImageUrl.value);
|
|
spritesheetImageUrl.value = '';
|
|
}
|
|
spritesheetImageFile.value = null;
|
|
};
|
|
|
|
// Handle the split spritesheet result
|
|
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
|
// Process sprite files with their positions
|
|
Promise.all(
|
|
spriteFiles.map(spriteFile => {
|
|
return new Promise<Sprite>(resolve => {
|
|
const url = URL.createObjectURL(spriteFile.file);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
resolve({
|
|
id: crypto.randomUUID(),
|
|
file: spriteFile.file,
|
|
img,
|
|
url,
|
|
width: img.width,
|
|
height: img.height,
|
|
x: spriteFile.x, // Use the position from the splitter
|
|
y: spriteFile.y, // Use the position from the splitter
|
|
});
|
|
};
|
|
img.src = url;
|
|
});
|
|
})
|
|
).then(newSprites => {
|
|
sprites.value = [...sprites.value, ...newSprites];
|
|
});
|
|
};
|
|
|
|
// Export spritesheet as JSON with base64 images
|
|
const exportSpritesheetJSON = async () => {
|
|
if (sprites.value.length === 0) return;
|
|
|
|
// Create an array to store sprite data with base64 images
|
|
const spritesData = await Promise.all(
|
|
sprites.value.map(async (sprite, index) => {
|
|
// Create a canvas for each sprite to get its base64 data
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return null;
|
|
|
|
// Set canvas size to match the sprite
|
|
canvas.width = sprite.width;
|
|
canvas.height = sprite.height;
|
|
|
|
// Draw the sprite
|
|
ctx.drawImage(sprite.img, 0, 0);
|
|
|
|
// Get base64 data
|
|
const base64Data = canvas.toDataURL('image/png');
|
|
|
|
return {
|
|
id: sprite.id,
|
|
width: sprite.width,
|
|
height: sprite.height,
|
|
x: sprite.x,
|
|
y: sprite.y,
|
|
base64: base64Data,
|
|
name: sprite.file.name,
|
|
};
|
|
})
|
|
);
|
|
|
|
// Create JSON object with all necessary data
|
|
const jsonData = {
|
|
columns: columns.value,
|
|
sprites: spritesData.filter(Boolean), // Remove any null values
|
|
};
|
|
|
|
// Convert to JSON string
|
|
const jsonString = JSON.stringify(jsonData, null, 2);
|
|
|
|
// Create download link
|
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.download = 'spritesheet.json';
|
|
link.href = url;
|
|
link.click();
|
|
|
|
// Clean up
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// Open file dialog for JSON import
|
|
const openJSONImportDialog = () => {
|
|
jsonFileInput.value?.click();
|
|
};
|
|
|
|
// Handle JSON file selection
|
|
const handleJSONFileChange = (event: Event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
if (input.files && input.files.length > 0) {
|
|
const jsonFile = input.files[0];
|
|
importSpritesheetJSON(jsonFile);
|
|
// Reset input value so uploading the same file again will trigger the event
|
|
if (jsonFileInput.value) jsonFileInput.value.value = '';
|
|
}
|
|
};
|
|
|
|
// Import spritesheet from JSON
|
|
const importSpritesheetJSON = async (jsonFile: File) => {
|
|
try {
|
|
const jsonText = await jsonFile.text();
|
|
const jsonData = JSON.parse(jsonText);
|
|
|
|
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) {
|
|
throw new Error('Invalid JSON format: missing sprites array');
|
|
}
|
|
|
|
// Set columns if available
|
|
if (jsonData.columns && typeof jsonData.columns === 'number') {
|
|
columns.value = jsonData.columns;
|
|
}
|
|
|
|
// Process each sprite
|
|
// Replace current sprites with imported ones
|
|
sprites.value = await Promise.all(
|
|
jsonData.sprites.map(async (spriteData: any) => {
|
|
return new Promise<Sprite>(resolve => {
|
|
// Create image from base64
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
// Create a file from the base64 data
|
|
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;
|
|
});
|
|
})
|
|
);
|
|
} catch (error) {
|
|
console.error('Error importing JSON:', error);
|
|
alert('Failed to import JSON file. Please check the file format.');
|
|
}
|
|
};
|
|
|
|
// Add new alignment function
|
|
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
|
if (sprites.value.length === 0) return;
|
|
|
|
// Find max dimensions for the current column layout
|
|
let maxWidth = 0;
|
|
let maxHeight = 0;
|
|
sprites.value.forEach(sprite => {
|
|
maxWidth = Math.max(maxWidth, sprite.width);
|
|
maxHeight = Math.max(maxHeight, sprite.height);
|
|
});
|
|
|
|
sprites.value = sprites.value.map((sprite, index) => {
|
|
let x = sprite.x;
|
|
let y = sprite.y;
|
|
|
|
switch (position) {
|
|
case 'left':
|
|
x = 0;
|
|
break;
|
|
case 'center':
|
|
x = Math.floor((maxWidth - sprite.width) / 2);
|
|
break;
|
|
case 'right':
|
|
x = Math.floor(maxWidth - sprite.width);
|
|
break;
|
|
case 'top':
|
|
y = 0;
|
|
break;
|
|
case 'middle':
|
|
y = Math.floor((maxHeight - sprite.height) / 2);
|
|
break;
|
|
case 'bottom':
|
|
y = Math.floor(maxHeight - sprite.height);
|
|
break;
|
|
}
|
|
|
|
// Ensure integer positions for pixel-perfect rendering
|
|
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
|
});
|
|
|
|
// Force redraw of the preview canvas
|
|
setTimeout(() => {
|
|
const event = new Event('forceRedraw');
|
|
window.dispatchEvent(event);
|
|
}, 0);
|
|
};
|
|
|
|
const updateSpriteCell = (id: string, newIndex: number) => {
|
|
// Find the current index of the sprite
|
|
const currentIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
|
if (currentIndex === -1) return;
|
|
|
|
// If we're trying to move to the same position, do nothing
|
|
if (currentIndex === newIndex) return;
|
|
|
|
// Create a copy of the sprites array
|
|
const newSprites = [...sprites.value];
|
|
|
|
// Perform a swap between the two positions
|
|
if (newIndex < sprites.value.length) {
|
|
// Get references to both sprites
|
|
const movingSprite = { ...newSprites[currentIndex] };
|
|
const targetSprite = { ...newSprites[newIndex] };
|
|
|
|
// Swap them
|
|
newSprites[currentIndex] = targetSprite;
|
|
newSprites[newIndex] = movingSprite;
|
|
} else {
|
|
// If dragging to an empty cell (beyond the array length)
|
|
// Use the original reordering logic
|
|
const [movedSprite] = newSprites.splice(currentIndex, 1);
|
|
newSprites.splice(newIndex, 0, movedSprite);
|
|
}
|
|
|
|
// Update the sprites array
|
|
sprites.value = newSprites;
|
|
};
|
|
</script>
|