first commit

This commit is contained in:
root
2025-05-05 08:52:16 +02:00
commit e3205d500d
41 changed files with 7772 additions and 0 deletions

View File

@@ -0,0 +1,441 @@
<template>
<Modal :is-open="isOpen" @close="cancel" title="Split Spritesheet">
<div class="space-y-6">
<div class="flex flex-col space-y-4">
<div class="flex items-center justify-center mb-4">
<img :src="imageUrl" alt="Spritesheet" class="max-w-full max-h-64 border border-gray-300 rounded-lg" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label for="detection-method" class="block text-sm font-medium text-gray-700">Detection Method</label>
<select id="detection-method" v-model="detectionMethod" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" data-umami-event="spritesheet-detection-method">
<option value="manual">Manual (specify rows and columns)</option>
<option value="auto">Auto-detect (experimental)</option>
</select>
</div>
<div v-if="detectionMethod === 'auto'" class="space-y-2">
<label for="sensitivity" class="block text-sm font-medium text-gray-700">Detection Sensitivity</label>
<input type="range" id="sensitivity" v-model="sensitivity" min="1" max="100" class="w-full" data-umami-event="spritesheet-sensitivity" />
<div class="text-xs text-gray-500 flex justify-between">
<span>Low</span>
<span>High</span>
</div>
</div>
<div v-if="detectionMethod === 'manual'" class="space-y-2">
<label for="rows" class="block text-sm font-medium text-gray-700">Rows</label>
<input type="number" id="rows" v-model.number="rows" min="1" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" data-umami-event="spritesheet-rows" />
</div>
<div v-if="detectionMethod === 'manual'" class="space-y-2">
<label for="columns" class="block text-sm font-medium text-gray-700">Columns</label>
<input type="number" id="columns" v-model.number="columns" min="1" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" data-umami-event="spritesheet-columns" />
</div>
</div>
<div class="space-y-2">
<div class="flex items-center">
<input type="checkbox" id="remove-empty" v-model="removeEmpty" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" data-umami-event="spritesheet-remove-empty" />
<label for="remove-empty" class="ml-2 block text-sm text-gray-700"> Remove empty sprites (transparent/background color) </label>
</div>
</div>
<div v-if="previewSprites.length > 0" class="space-y-2">
<h3 class="text-sm font-medium text-gray-700">Preview ({{ previewSprites.length }} sprites)</h3>
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2 max-h-40 overflow-y-auto p-2 border border-gray-200 rounded-lg">
<div v-for="(sprite, index) in previewSprites" :key="index" class="relative border border-gray-300 rounded bg-gray-100 flex items-center justify-center" :style="{ width: '80px', height: '80px' }">
<img :src="sprite.url" alt="Sprite preview" class="max-w-full max-h-full" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<button @click="cancel" class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" data-umami-event="spritesheet-cancel">Cancel</button>
<button
@click="confirm"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
:disabled="previewSprites.length === 0 || isProcessing"
data-umami-event="spritesheet-split"
>
Split Spritesheet
</button>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import Modal from './utilities/Modal.vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
interface SpritePreview {
url: string;
x: number;
y: number;
width: number;
height: number;
isEmpty: boolean;
}
const props = defineProps<{
isOpen: boolean;
imageUrl: string;
imageFile: File | null | undefined;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[]
}>();
interface SpriteFile {
file: File;
x: number;
y: number;
width: number;
height: number;
}
// Get settings from store
const settingsStore = useSettingsStore();
// State
const detectionMethod = ref<'manual' | 'auto'>('manual');
const rows = ref(1);
const columns = ref(1);
const sensitivity = ref(50);
const removeEmpty = ref(true);
const previewSprites = ref<SpritePreview[]>([]);
const isProcessing = ref(false);
const imageElement = ref<HTMLImageElement | null>(null);
// Load the image when the component is mounted or the URL changes
watch(() => props.imageUrl, loadImage, { immediate: true });
function loadImage() {
if (!props.imageUrl) return;
const img = new Image();
img.onload = () => {
imageElement.value = img;
// Set default rows and columns based on image dimensions
// This is a simple heuristic - for pixel art, we might want to detect sprite size
const aspectRatio = img.width / img.height;
if (aspectRatio > 1) {
// Landscape orientation - likely more columns than rows
columns.value = Math.min(Math.ceil(Math.sqrt(aspectRatio * 4)), 8);
rows.value = Math.ceil(4 / columns.value);
} else {
// Portrait orientation - likely more rows than columns
rows.value = Math.min(Math.ceil(Math.sqrt(4 / aspectRatio)), 8);
columns.value = Math.ceil(4 / rows.value);
}
// Generate initial preview
generatePreview();
};
img.src = props.imageUrl;
}
// Generate preview of split sprites
async function generatePreview() {
if (!imageElement.value) return;
isProcessing.value = true;
previewSprites.value = [];
try {
const img = imageElement.value;
if (detectionMethod.value === 'auto') {
// Auto-detection logic would go here
// For now, we'll use a simple algorithm based on sensitivity
await autoDetectSprites(img);
} else {
// Manual splitting based on rows and columns
await splitSpritesheet(img, rows.value, columns.value);
}
} catch (error) {
console.error('Error generating preview:', error);
} finally {
isProcessing.value = false;
}
}
function getSpriteBoundingBox(ctx: CanvasRenderingContext2D, width: number, height: number) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
let minX = width;
let minY = height;
let maxX = 0;
let maxY = 0;
let hasContent = false;
// Scan through all pixels to find the bounding box
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
// Check if pixel is not transparent (alpha > 0)
if (data[idx + 3] > 10) {
// Allow some tolerance for compression artifacts
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
hasContent = true;
}
}
}
// If no non-transparent pixels found, return null
if (!hasContent) {
return null;
}
// Return bounding box with a small padding
return {
x: Math.max(0, minX - 1),
y: Math.max(0, minY - 1),
width: Math.min(width, maxX - minX + 3), // +1 for inclusive bounds, +2 for padding
height: Math.min(height, maxY - minY + 3),
};
}
// Split spritesheet manually based on rows and columns
async function splitSpritesheet(img: HTMLImageElement, rows: number, columns: number) {
const spriteWidth = Math.floor(img.width / columns);
const spriteHeight = Math.floor(img.height / rows);
const sprites: SpritePreview[] = [];
// Create a canvas for processing the full sprite
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Create a second canvas for the cropped sprite
const croppedCanvas = document.createElement('canvas');
const croppedCtx = croppedCanvas.getContext('2d');
if (!ctx || !croppedCtx) return;
canvas.width = spriteWidth;
canvas.height = spriteHeight;
// Split the image into individual sprites
for (let row = 0; row < rows; row++) {
for (let col = 0; col < columns; col++) {
// Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw the portion of the spritesheet
ctx.drawImage(img, col * spriteWidth, row * spriteHeight, spriteWidth, spriteHeight, 0, 0, spriteWidth, spriteHeight);
// Check if the sprite is empty (all transparent or same color)
const isEmpty = removeEmpty.value ? isCanvasEmpty(ctx, spriteWidth, spriteHeight) : false;
// If we're not removing empty sprites or the sprite is not empty
if (!removeEmpty.value || !isEmpty) {
// Get bounding box of non-transparent pixels
const boundingBox = getSpriteBoundingBox(ctx, spriteWidth, spriteHeight);
let url;
let x = 0; // Default position (will be updated if we have a bounding box)
let y = 0;
let width = spriteWidth;
let height = spriteHeight;
if (boundingBox) {
// The key change: preserve the original position where the sprite was found
x = boundingBox.x;
y = boundingBox.y;
width = boundingBox.width;
height = boundingBox.height;
// Set dimensions for the cropped sprite
croppedCanvas.width = boundingBox.width;
croppedCanvas.height = boundingBox.height;
// Draw only the non-transparent part
croppedCtx.clearRect(0, 0, croppedCanvas.width, croppedCanvas.height);
croppedCtx.drawImage(canvas, boundingBox.x, boundingBox.y, boundingBox.width, boundingBox.height, 0, 0, boundingBox.width, boundingBox.height);
// Convert to data URL
url = croppedCanvas.toDataURL('image/png');
} else {
// No non-transparent pixels found, use the original sprite
url = canvas.toDataURL('image/png');
}
sprites.push({
url,
x,
y,
width,
height,
isEmpty,
});
}
}
}
previewSprites.value = sprites;
}
// Auto-detect sprites based on transparency/color differences
async function autoDetectSprites(img: HTMLImageElement) {
// This is a simplified implementation
// A more sophisticated algorithm would analyze the image to find sprite boundaries
// For now, we'll use a simple approach:
// 1. Try to detect the sprite size by looking for repeating patterns
// 2. Then use that size to split the spritesheet
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Get image data for analysis
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Simple detection of sprite size based on transparency patterns
// This is a very basic implementation and might not work for all spritesheets
const { detectedWidth, detectedHeight } = detectSpriteSize(data, canvas.width, canvas.height);
if (detectedWidth > 0 && detectedHeight > 0) {
const detectedRows = Math.floor(img.height / detectedHeight);
const detectedColumns = Math.floor(img.width / detectedWidth);
// Use the detected size to split the spritesheet
await splitSpritesheet(img, detectedRows, detectedColumns);
} else {
// Fallback to manual splitting with a reasonable guess
const estimatedSize = Math.max(16, Math.floor(Math.min(img.width, img.height) / 8));
const estimatedRows = Math.floor(img.height / estimatedSize);
const estimatedColumns = Math.floor(img.width / estimatedSize);
await splitSpritesheet(img, estimatedRows, estimatedColumns);
}
}
// Helper function to detect sprite size based on transparency patterns
function detectSpriteSize(data: Uint8ClampedArray, width: number, height: number) {
// This is a simplified implementation
// A real implementation would be more sophisticated
// The sensitivity affects how aggressive we are in detecting patterns
const threshold = 100 - sensitivity.value; // Lower threshold = more sensitive
// For now, return a simple estimate based on image size
// In a real implementation, we would analyze the image data to find patterns
return {
detectedWidth: 0, // Return 0 to fall back to the manual method
detectedHeight: 0,
};
}
// Check if a canvas is empty (all transparent or same color)
function isCanvasEmpty(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
// Check if all pixels are transparent
let allTransparent = true;
let allSameColor = true;
// Reference values from first pixel
const firstR = data[0];
const firstG = data[1];
const firstB = data[2];
const firstA = data[3];
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
// Check transparency
if (alpha > 10) {
// Allow some tolerance for compression artifacts
allTransparent = false;
}
// Check if all pixels are the same color
if (data[i] !== firstR || data[i + 1] !== firstG || data[i + 2] !== firstB || Math.abs(data[i + 3] - firstA) > 10) {
allSameColor = false;
}
// Early exit if we've determined it's not empty
if (!allTransparent && !allSameColor) {
break;
}
}
return allTransparent || allSameColor;
}
// Convert preview sprites to actual files
async function createSpriteFiles(): Promise<SpriteFile[]> {
const spriteFiles: SpriteFile[] = [];
for (let i = 0; i < previewSprites.value.length; i++) {
const sprite = previewSprites.value[i];
// Convert data URL to blob
const response = await fetch(sprite.url);
const blob = await response.blob();
// Create file from blob
const fileName = `sprite_${i + 1}.png`;
const file = new File([blob], fileName, { type: 'image/png' });
// Create sprite file with position information
spriteFiles.push({
file,
x: sprite.x,
y: sprite.y,
width: sprite.width,
height: sprite.height,
});
}
return spriteFiles;
}
// Actions
function cancel() {
emit('close');
}
async function confirm() {
if (previewSprites.value.length === 0) return;
isProcessing.value = true;
try {
const files = await createSpriteFiles();
emit('split', files);
emit('close');
} catch (error) {
console.error('Error creating sprite files:', error);
} finally {
isProcessing.value = false;
}
}
// Add these watchers to automatically update preview
watch([rows, columns, removeEmpty, detectionMethod, sensitivity], () => {
if (imageElement.value) {
generatePreview();
}
});
</script>