Split spritesheet, add sort functionality
This commit is contained in:
parent
335bc03218
commit
08bb5ebc91
@ -1,6 +1,11 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [1.0.1] - 2025-04-06
|
## [1.2.0] - 2025-04-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Import and split existing spritesheet functionality
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-04-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- 📝 Help modal with instructions and tips
|
- 📝 Help modal with instructions and tips
|
||||||
|
77
src/App.vue
77
src/App.vue
@ -64,7 +64,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -74,6 +74,7 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
||||||
|
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -84,6 +85,7 @@
|
|||||||
import Modal from './components/utilities/Modal.vue';
|
import Modal from './components/utilities/Modal.vue';
|
||||||
import SpritePreview from './components/SpritePreview.vue';
|
import SpritePreview from './components/SpritePreview.vue';
|
||||||
import HelpModal from './components/HelpModal.vue';
|
import HelpModal from './components/HelpModal.vue';
|
||||||
|
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||||||
|
|
||||||
interface Sprite {
|
interface Sprite {
|
||||||
id: string;
|
id: string;
|
||||||
@ -100,7 +102,10 @@
|
|||||||
const columns = ref(4);
|
const columns = ref(4);
|
||||||
const isPreviewModalOpen = ref(false);
|
const isPreviewModalOpen = ref(false);
|
||||||
const isHelpModalOpen = ref(false);
|
const isHelpModalOpen = ref(false);
|
||||||
|
const isSpritesheetSplitterOpen = ref(false);
|
||||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const spritesheetImageUrl = ref('');
|
||||||
|
const spritesheetImageFile = ref<File | null>(null);
|
||||||
|
|
||||||
const handleSpritesUpload = (files: File[]) => {
|
const handleSpritesUpload = (files: File[]) => {
|
||||||
// Check if any of the files is a JSON file
|
// Check if any of the files is a JSON file
|
||||||
@ -112,7 +117,40 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, process as normal image files
|
// 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 = () => {
|
||||||
|
// If the image is large enough, it might be a spritesheet
|
||||||
|
// This is a simple heuristic - we can improve it later
|
||||||
|
if (img.width > 200 && img.height > 200) {
|
||||||
|
// 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(
|
Promise.all(
|
||||||
files.map(file => {
|
files.map(file => {
|
||||||
return new Promise<Sprite>(resolve => {
|
return new Promise<Sprite>(resolve => {
|
||||||
@ -211,6 +249,23 @@
|
|||||||
isHelpModalOpen.value = false;
|
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 = (files: File[]) => {
|
||||||
|
// Process the split sprite files
|
||||||
|
processImageFiles(files);
|
||||||
|
};
|
||||||
|
|
||||||
// Export spritesheet as JSON with base64 images
|
// Export spritesheet as JSON with base64 images
|
||||||
const exportSpritesheetJSON = async () => {
|
const exportSpritesheetJSON = async () => {
|
||||||
if (sprites.value.length === 0) return;
|
if (sprites.value.length === 0) return;
|
||||||
@ -380,4 +435,22 @@
|
|||||||
return { ...sprite, x, y };
|
return { ...sprite, x, y };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Create a new sprites array
|
||||||
|
const newSprites = [...sprites.value];
|
||||||
|
|
||||||
|
// Remove the sprite from its current position
|
||||||
|
const [movedSprite] = newSprites.splice(currentIndex, 1);
|
||||||
|
|
||||||
|
// Insert the sprite at the new position
|
||||||
|
newSprites.splice(newIndex, 0, movedSprite);
|
||||||
|
|
||||||
|
// Update the sprites array
|
||||||
|
sprites.value = newSprites;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
<h4 class="mt-6 mb-3 text-lg font-medium text-gray-900">How to use:</h4>
|
<h4 class="mt-6 mb-3 text-lg font-medium text-gray-900">How to use:</h4>
|
||||||
<ol class="list-decimal pl-6 space-y-2 mb-4">
|
<ol class="list-decimal pl-6 space-y-2 mb-4">
|
||||||
<li>Upload your sprite images by dragging and dropping them or clicking the upload area</li>
|
<li>Upload your sprite images by dragging and dropping them or clicking the upload area</li>
|
||||||
|
<li>If you upload a single large image, you'll be asked if you want to split it into individual sprites</li>
|
||||||
<li>Arrange your sprites by dragging them to the desired position</li>
|
<li>Arrange your sprites by dragging them to the desired position</li>
|
||||||
<li>Adjust the number of columns to change the layout</li>
|
<li>Adjust the number of columns to change the layout</li>
|
||||||
<li>Preview your animation using the "Preview Animation" button</li>
|
<li>Preview your animation using the "Preview Animation" button</li>
|
||||||
@ -41,6 +42,8 @@
|
|||||||
<h4 class="mt-6 mb-3 text-lg font-medium text-gray-900">Tips:</h4>
|
<h4 class="mt-6 mb-3 text-lg font-medium text-gray-900">Tips:</h4>
|
||||||
<ul class="list-disc pl-6 space-y-2">
|
<ul class="list-disc pl-6 space-y-2">
|
||||||
<li>For best results, use sprites with consistent dimensions</li>
|
<li>For best results, use sprites with consistent dimensions</li>
|
||||||
|
<li>When uploading a spritesheet, you can split it automatically into individual sprites</li>
|
||||||
|
<li>The spritesheet splitter allows you to specify rows and columns or try auto-detection</li>
|
||||||
<li>The preview animation plays frames in the order they appear in the spritesheet (left to right, top to bottom)</li>
|
<li>The preview animation plays frames in the order they appear in the spritesheet (left to right, top to bottom)</li>
|
||||||
<li>You can adjust the animation speed in the preview window</li>
|
<li>You can adjust the animation speed in the preview window</li>
|
||||||
<li>The tool works entirely in your browser - no files are uploaded to any server</li>
|
<li>The tool works entirely in your browser - no files are uploaded to any server</li>
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center gap-4">
|
||||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2" @change="drawCanvas" />
|
<div class="flex items-center">
|
||||||
<label for="pixel-perfect">Pixel perfect rendering (for pixel art)</label>
|
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2" @change="drawCanvas" />
|
||||||
|
<label for="pixel-perfect">Pixel perfect rendering (for pixel art)</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="mr-2" />
|
||||||
|
<label for="allow-cell-swap">Allow moving between cells</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -26,6 +32,12 @@
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CellPosition {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sprites: Sprite[];
|
sprites: Sprite[];
|
||||||
columns: number;
|
columns: number;
|
||||||
@ -33,6 +45,7 @@
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||||
|
(e: 'updateSpriteCell', id: string, newIndex: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Get settings from store
|
// Get settings from store
|
||||||
@ -41,12 +54,20 @@
|
|||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||||
|
|
||||||
// Dragging state
|
// State for tracking drag operations
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const activeSpriteId = ref<string | null>(null);
|
const activeSpriteId = ref<string | null>(null);
|
||||||
|
const activeSpriteCellIndex = ref<number | null>(null);
|
||||||
const dragStartX = ref(0);
|
const dragStartX = ref(0);
|
||||||
const dragStartY = ref(0);
|
const dragStartY = ref(0);
|
||||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
const dragOffsetX = ref(0);
|
||||||
|
const dragOffsetY = ref(0);
|
||||||
|
const allowCellSwap = ref(false);
|
||||||
|
const currentHoverCell = ref<CellPosition | null>(null);
|
||||||
|
|
||||||
|
// Visual feedback refs
|
||||||
|
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const highlightCell = ref<CellPosition | null>(null);
|
||||||
|
|
||||||
const spritePositions = computed(() => {
|
const spritePositions = computed(() => {
|
||||||
if (!canvasRef.value) return [];
|
if (!canvasRef.value) return [];
|
||||||
@ -67,6 +88,9 @@
|
|||||||
height: sprite.height,
|
height: sprite.height,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
index,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -80,7 +104,8 @@
|
|||||||
maxHeight = Math.max(maxHeight, sprite.height);
|
maxHeight = Math.max(maxHeight, sprite.height);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { maxWidth, maxHeight };
|
// Add some padding to ensure sprites have room to move
|
||||||
|
return { maxWidth: maxWidth, maxHeight: maxHeight };
|
||||||
};
|
};
|
||||||
|
|
||||||
const startDrag = (event: MouseEvent) => {
|
const startDrag = (event: MouseEvent) => {
|
||||||
@ -94,23 +119,47 @@
|
|||||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
||||||
|
|
||||||
if (clickedSprite) {
|
if (clickedSprite) {
|
||||||
isDragging.value = true;
|
isDragging.value = true;
|
||||||
activeSpriteId.value = clickedSprite.id;
|
activeSpriteId.value = clickedSprite.id;
|
||||||
dragStartX.value = mouseX;
|
dragStartX.value = mouseX;
|
||||||
dragStartY.value = mouseY;
|
dragStartY.value = mouseY;
|
||||||
|
|
||||||
// Find current sprite position
|
// Find the sprite's position to calculate offset from mouse to sprite origin
|
||||||
const sprite = props.sprites.find(s => s.id === clickedSprite.id);
|
const spritePosition = spritePositions.value.find(pos => pos.id === clickedSprite.id);
|
||||||
if (sprite) {
|
if (spritePosition) {
|
||||||
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
dragOffsetX.value = mouseX - spritePosition.canvasX;
|
||||||
|
dragOffsetY.value = mouseY - spritePosition.canvasY;
|
||||||
|
activeSpriteCellIndex.value = spritePosition.index;
|
||||||
|
|
||||||
|
// Store the starting cell position
|
||||||
|
const startCell = findCellAtPosition(mouseX, mouseY);
|
||||||
|
if (startCell) {
|
||||||
|
currentHoverCell.value = startCell;
|
||||||
|
highlightCell.value = null; // No highlight at the start
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
|
||||||
|
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||||
|
const col = Math.floor(x / maxWidth);
|
||||||
|
const row = Math.floor(y / maxHeight);
|
||||||
|
|
||||||
|
// Check if the cell position is valid
|
||||||
|
const totalRows = Math.ceil(props.sprites.length / props.columns);
|
||||||
|
if (col >= 0 && col < props.columns && row >= 0 && row < totalRows) {
|
||||||
|
const index = row * props.columns + col;
|
||||||
|
if (index < props.sprites.length) {
|
||||||
|
return { col, row, index };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const drag = (event: MouseEvent) => {
|
const drag = (event: MouseEvent) => {
|
||||||
if (!isDragging.value || !activeSpriteId.value || !canvasRef.value) return;
|
if (!isDragging.value || !activeSpriteId.value || !canvasRef.value || activeSpriteCellIndex.value === null) return;
|
||||||
|
|
||||||
const rect = canvasRef.value.getBoundingClientRect();
|
const rect = canvasRef.value.getBoundingClientRect();
|
||||||
const scaleX = canvasRef.value.width / rect.width;
|
const scaleX = canvasRef.value.width / rect.width;
|
||||||
@ -119,50 +168,101 @@
|
|||||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
const deltaX = mouseX - dragStartX.value;
|
|
||||||
const deltaY = mouseY - dragStartY.value;
|
|
||||||
|
|
||||||
// Calculate new position with constraints
|
|
||||||
const spriteIndex = props.sprites.findIndex(s => s.id === activeSpriteId.value);
|
const spriteIndex = props.sprites.findIndex(s => s.id === activeSpriteId.value);
|
||||||
if (spriteIndex === -1) return;
|
if (spriteIndex === -1) return;
|
||||||
|
|
||||||
|
// Find the cell the mouse is currently over
|
||||||
|
const hoverCell = findCellAtPosition(mouseX, mouseY);
|
||||||
|
currentHoverCell.value = hoverCell;
|
||||||
|
|
||||||
|
if (allowCellSwap.value && hoverCell) {
|
||||||
|
// If we're hovering over a different cell than the sprite's current cell
|
||||||
|
if (hoverCell.index !== activeSpriteCellIndex.value) {
|
||||||
|
// Show a highlight for the target cell
|
||||||
|
highlightCell.value = hoverCell;
|
||||||
|
|
||||||
|
// Create a ghost sprite that follows the mouse
|
||||||
|
ghostSprite.value = {
|
||||||
|
id: activeSpriteId.value,
|
||||||
|
x: mouseX - dragOffsetX.value,
|
||||||
|
y: mouseY - dragOffsetY.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
drawCanvas();
|
||||||
|
} else {
|
||||||
|
// Same cell as the sprite's origin, just do regular movement
|
||||||
|
highlightCell.value = null;
|
||||||
|
ghostSprite.value = null;
|
||||||
|
handleInCellMovement(mouseX, mouseY, spriteIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular in-cell movement
|
||||||
|
handleInCellMovement(mouseX, mouseY, spriteIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
|
||||||
|
if (!activeSpriteId.value) return;
|
||||||
|
|
||||||
const position = spritePositions.value.find(pos => pos.id === activeSpriteId.value);
|
const position = spritePositions.value.find(pos => pos.id === activeSpriteId.value);
|
||||||
if (!position) return;
|
if (!position) return;
|
||||||
|
|
||||||
let newX = spritePosBeforeDrag.value.x + deltaX;
|
// Calculate new position based on mouse position and initial click offset
|
||||||
let newY = spritePosBeforeDrag.value.y + deltaY;
|
const newX = mouseX - position.cellX - dragOffsetX.value;
|
||||||
|
const newY = mouseY - position.cellY - dragOffsetY.value;
|
||||||
|
|
||||||
// Constrain movement strictly within cell
|
// Constrain within cell boundaries
|
||||||
newX = Math.max(0, Math.min(position.maxWidth - props.sprites[spriteIndex].width, newX));
|
const constrainedX = Math.max(0, Math.min(position.maxWidth - props.sprites[spriteIndex].width, newX));
|
||||||
newY = Math.max(0, Math.min(position.maxHeight - props.sprites[spriteIndex].height, newY));
|
const constrainedY = Math.max(0, Math.min(position.maxHeight - props.sprites[spriteIndex].height, newY));
|
||||||
|
|
||||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
emit('updateSprite', activeSpriteId.value, constrainedX, constrainedY);
|
||||||
drawCanvas();
|
drawCanvas();
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopDrag = () => {
|
const stopDrag = () => {
|
||||||
|
if (isDragging.value && allowCellSwap.value && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
|
||||||
|
// We've dragged from one cell to another
|
||||||
|
// Tell parent component to update the sprite's cell index
|
||||||
|
emit('updateSpriteCell', activeSpriteId.value, currentHoverCell.value.index);
|
||||||
|
|
||||||
|
// Also reset the sprite's position within the cell to 0,0
|
||||||
|
emit('updateSprite', activeSpriteId.value, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all drag state
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
activeSpriteId.value = null;
|
activeSpriteId.value = null;
|
||||||
|
activeSpriteCellIndex.value = null;
|
||||||
|
currentHoverCell.value = null;
|
||||||
|
highlightCell.value = null;
|
||||||
|
ghostSprite.value = null;
|
||||||
|
|
||||||
|
// Redraw without highlights
|
||||||
|
drawCanvas();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
if (event.touches.length === 1) {
|
if (event.touches.length === 1) {
|
||||||
const touch = event.touches[0];
|
const touch = event.touches[0];
|
||||||
const mouseEvent = new MouseEvent('mousedown', {
|
const mouseEvent = {
|
||||||
clientX: touch.clientX,
|
clientX: touch.clientX,
|
||||||
clientY: touch.clientY,
|
clientY: touch.clientY,
|
||||||
});
|
preventDefault: () => {},
|
||||||
|
} as unknown as MouseEvent;
|
||||||
startDrag(mouseEvent);
|
startDrag(mouseEvent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMove = (event: TouchEvent) => {
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
if (event.touches.length === 1) {
|
if (event.touches.length === 1) {
|
||||||
const touch = event.touches[0];
|
const touch = event.touches[0];
|
||||||
const mouseEvent = new MouseEvent('mousemove', {
|
const mouseEvent = {
|
||||||
clientX: touch.clientX,
|
clientX: touch.clientX,
|
||||||
clientY: touch.clientY,
|
clientY: touch.clientY,
|
||||||
});
|
preventDefault: () => {},
|
||||||
|
} as unknown as MouseEvent;
|
||||||
drag(mouseEvent);
|
drag(mouseEvent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -198,25 +298,47 @@
|
|||||||
// Disable image smoothing based on pixel perfect setting
|
// Disable image smoothing based on pixel perfect setting
|
||||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||||
|
|
||||||
// Draw grid
|
// Draw grid and highlights
|
||||||
ctx.value.strokeStyle = '#e5e7eb';
|
ctx.value.strokeStyle = '#e5e7eb';
|
||||||
for (let col = 0; col < props.columns; col++) {
|
for (let col = 0; col < props.columns; col++) {
|
||||||
for (let row = 0; row < rows; row++) {
|
for (let row = 0; row < rows; row++) {
|
||||||
|
// Highlight the target cell if specified
|
||||||
|
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
|
||||||
|
ctx.value.fillStyle = 'rgba(59, 130, 246, 0.2)'; // Light blue highlight
|
||||||
|
ctx.value.fillRect(Math.floor(col * maxWidth), Math.floor(row * maxHeight), maxWidth, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
ctx.value.strokeRect(Math.floor(col * maxWidth), Math.floor(row * maxHeight), maxWidth, maxHeight);
|
ctx.value.strokeRect(Math.floor(col * maxWidth), Math.floor(row * maxHeight), maxWidth, maxHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw sprites
|
// Draw sprites
|
||||||
props.sprites.forEach((sprite, index) => {
|
props.sprites.forEach((sprite, index) => {
|
||||||
|
// Skip the active sprite if we're showing a ghost instead
|
||||||
|
if (activeSpriteId.value === sprite.id && ghostSprite.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const col = index % props.columns;
|
const col = index % props.columns;
|
||||||
const row = Math.floor(index / props.columns);
|
const row = Math.floor(index / props.columns);
|
||||||
|
|
||||||
const cellX = Math.floor(col * maxWidth);
|
const cellX = Math.floor(col * maxWidth);
|
||||||
const cellY = Math.floor(row * maxHeight);
|
const cellY = Math.floor(row * maxHeight);
|
||||||
|
|
||||||
// Draw sprite using integer positions
|
// Draw sprite using integer positions for pixel-perfect rendering
|
||||||
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Draw ghost sprite if we're dragging between cells
|
||||||
|
if (ghostSprite.value && activeSpriteId.value) {
|
||||||
|
const sprite = props.sprites.find(s => s.id === activeSpriteId.value);
|
||||||
|
if (sprite) {
|
||||||
|
// Semi-transparent ghost
|
||||||
|
ctx.value.globalAlpha = 0.6;
|
||||||
|
ctx.value.drawImage(sprite.img, Math.floor(ghostSprite.value.x), Math.floor(ghostSprite.value.y));
|
||||||
|
ctx.value.globalAlpha = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
348
src/components/SpritesheetSplitter.vue
Normal file
348
src/components/SpritesheetSplitter.vue
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
<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">
|
||||||
|
<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" />
|
||||||
|
<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" />
|
||||||
|
</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" />
|
||||||
|
</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" />
|
||||||
|
<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">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">
|
||||||
|
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: File[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) 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) {
|
||||||
|
// Convert to data URL
|
||||||
|
const url = canvas.toDataURL('image/png');
|
||||||
|
|
||||||
|
sprites.push({
|
||||||
|
url,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: spriteWidth,
|
||||||
|
height: spriteHeight,
|
||||||
|
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<File[]> {
|
||||||
|
const files: File[] = [];
|
||||||
|
|
||||||
|
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' });
|
||||||
|
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
Loading…
x
Reference in New Issue
Block a user