import { ref, reactive, computed } from 'vue'; export interface Sprite { img: HTMLImageElement; width: number; height: number; x: number; y: number; name: string; id: string; uploadOrder: number; } export interface CellSize { width: number; height: number; } export interface AnimationState { canvas: HTMLCanvasElement | null; ctx: CanvasRenderingContext2D | null; currentFrame: number; isPlaying: boolean; frameRate: number; lastFrameTime: number; animationId: number | null; slider: HTMLInputElement | null; manualUpdate: boolean; } const sprites = ref([]); const canvas = ref(null); const ctx = ref(null); const cellSize = reactive({ width: 0, height: 0 }); const columns = ref(4); // Default number of columns const draggedSprite = ref(null); const dragOffset = reactive({ x: 0, y: 0 }); const isShiftPressed = ref(false); const isModalOpen = ref(false); const isSettingsModalOpen = ref(false); const isSpritesModalOpen = ref(false); const isHelpModalOpen = ref(false); const zoomLevel = ref(1); // Default zoom level (1 = 100%) // Preview border settings const previewBorder = reactive({ enabled: false, color: '#ff0000', // Default red color width: 2, // Default width in pixels }); export function useSpritesheetStore() { const animation = reactive({ canvas: null, ctx: null, currentFrame: 0, isPlaying: false, frameRate: 10, lastFrameTime: 0, animationId: null, slider: null, manualUpdate: false, }); const notification = reactive({ isVisible: false, message: '', type: 'success' as 'success' | 'error', }); function addSprites(newSprites: Sprite[]) { if (newSprites.length === 0) { console.warn('Store: Attempted to add empty sprites array'); return; } try { // Validate sprites before adding them const validSprites = newSprites.filter(sprite => { if (!sprite.img || sprite.width <= 0 || sprite.height <= 0) { console.error('Store: Invalid sprite detected', sprite); return false; } return true; }); if (validSprites.length === 0) { console.error('Store: No valid sprites to add'); return; } sprites.value.push(...validSprites); sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder); updateCellSize(); autoArrangeSprites(); } catch (error) { console.error('Store: Error adding sprites:', error); } } function updateCellSize() { if (sprites.value.length === 0) { return; } try { let maxWidth = 0; let maxHeight = 0; sprites.value.forEach(sprite => { if (sprite.width <= 0 || sprite.height <= 0) { console.warn('Store: Sprite with invalid dimensions detected', sprite); return; } maxWidth = Math.max(maxWidth, sprite.width); maxHeight = Math.max(maxHeight, sprite.height); }); if (maxWidth === 0 || maxHeight === 0) { console.error('Store: Failed to calculate valid cell size'); return; } cellSize.width = maxWidth; cellSize.height = maxHeight; updateCanvasSize(); } catch (error) { console.error('Store: Error updating cell size:', error); } } function updateCanvasSize() { if (!canvas.value) { console.warn('Store: Canvas not available for size update'); return; } if (sprites.value.length === 0) { return; } try { const totalSprites = sprites.value.length; const cols = columns.value; const rows = Math.ceil(totalSprites / cols); if (cellSize.width <= 0 || cellSize.height <= 0) { console.error('Store: Invalid cell size for canvas update', cellSize); return; } const newWidth = cols * cellSize.width; const newHeight = rows * cellSize.height; // Ensure the canvas is large enough to display all sprites if (canvas.value.width !== newWidth || canvas.value.height !== newHeight) { canvas.value.width = newWidth; canvas.value.height = newHeight; // Emit an event to update the wrapper dimensions window.dispatchEvent( new CustomEvent('canvas-size-updated', { detail: { width: newWidth, height: newHeight }, }) ); } } catch (error) { console.error('Store: Error updating canvas size:', error); } } function autoArrangeSprites() { if (sprites.value.length === 0) { return; } try { if (cellSize.width <= 0 || cellSize.height <= 0) { console.error('Store: Invalid cell size for auto-arranging', cellSize); return; } // First update the canvas size to ensure it's large enough updateCanvasSize(); // Then position each sprite in its grid cell sprites.value.forEach((sprite, index) => { const column = index % columns.value; const row = Math.floor(index / columns.value); sprite.x = column * cellSize.width; sprite.y = row * cellSize.height; }); // Check if the canvas is ready before attempting to render if (!ctx.value || !canvas.value) { console.warn('Store: Canvas or context not available for rendering after auto-arrange'); return; } renderSpritesheetPreview(); if (!animation.isPlaying && animation.manualUpdate && isModalOpen.value) { renderAnimationFrame(animation.currentFrame); } } catch (error) { console.error('Store: Error auto-arranging sprites:', error); } } function renderSpritesheetPreview(showGrid = true) { if (!ctx.value || !canvas.value) { console.error('Store: Canvas or context not available for rendering, will retry when ready'); setTimeout(() => { if (ctx.value && canvas.value) { renderSpritesheetPreview(showGrid); } }, 100); return; } if (sprites.value.length === 0) return; try { // Make sure the canvas size is correct before rendering updateCanvasSize(); // Clear the canvas ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height); if (showGrid) { drawGrid(); } // First, collect all occupied cells const occupiedCells = new Set(); sprites.value.forEach(sprite => { const cellX = Math.floor(sprite.x / cellSize.width); const cellY = Math.floor(sprite.y / cellSize.height); occupiedCells.add(`${cellX},${cellY}`); }); // Draw each sprite - remove the zoom scaling from context sprites.value.forEach((sprite, index) => { try { if (!sprite.img) { console.warn(`Store: Sprite at index ${index} has no image, skipping render`); return; } // Check if sprite is within canvas bounds if (sprite.x >= 0 && sprite.y >= 0 && sprite.x + sprite.width <= canvas.value!.width && sprite.y + sprite.height <= canvas.value!.height) { if (sprite.img.complete && sprite.img.naturalWidth !== 0) { // For pixel art, ensure we're drawing at exact pixel boundaries const x = Math.round(sprite.x); const y = Math.round(sprite.y); // Get the frame-specific offset for this sprite const frameOffset = getSpriteOffset(index); // Apply the frame-specific offset to the sprite position const finalX = x + frameOffset.x; const finalY = y + frameOffset.y; // Draw the image at its final position with pixel-perfect rendering ctx.value!.imageSmoothingEnabled = false; // Keep pixel art sharp ctx.value!.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height); } else { console.warn(`Store: Sprite image ${index} not fully loaded, setting onload handler`); sprite.img.onload = () => { if (ctx.value && canvas.value) { // For pixel art, ensure we're drawing at exact pixel boundaries const x = Math.round(sprite.x); const y = Math.round(sprite.y); // Get the frame-specific offset for this sprite const frameOffset = getSpriteOffset(index); // Apply the frame-specific offset to the sprite position const finalX = x + frameOffset.x; const finalY = y + frameOffset.y; ctx.value.imageSmoothingEnabled = false; // Keep pixel art sharp ctx.value.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height); } }; } } else { console.warn(`Store: Sprite at index ${index} is outside canvas bounds: sprite(${sprite.x},${sprite.y}) canvas(${canvas.value!.width},${canvas.value!.height})`); } } catch (spriteError) { console.error(`Store: Error rendering sprite at index ${index}:`, spriteError); } }); // Draw borders around occupied cells if enabled (preview only) if (previewBorder.enabled && occupiedCells.size > 0) { ctx.value!.strokeStyle = previewBorder.color; ctx.value!.lineWidth = previewBorder.width / zoomLevel.value; // Adjust for zoom // Draw borders around each occupied cell occupiedCells.forEach(cellKey => { const [cellX, cellY] = cellKey.split(',').map(Number); // Calculate pixel-perfect coordinates for the cell // Add 0.5 to align with pixel boundaries for crisp lines const x = Math.floor(cellX * cellSize.width) + 0.5; const y = Math.floor(cellY * cellSize.height) + 0.5; // Adjust width and height to ensure the border is inside the cell const width = cellSize.width - 1; const height = cellSize.height - 1; ctx.value!.strokeRect(x, y, width, height); }); } } catch (error) { console.error('Store: Error in renderSpritesheetPreview:', error); } } function applyOffsetsToMainView() { sprites.value.forEach((sprite, index) => { const frameOffset = getSpriteOffset(index); if (frameOffset.x !== 0 || frameOffset.y !== 0) { // Update the sprite's position to include the offset sprite.x += frameOffset.x; sprite.y += frameOffset.y; // Reset the offset frameOffset.x = 0; frameOffset.y = 0; } }); // Reset current offset currentSpriteOffset.x = 0; currentSpriteOffset.y = 0; // Re-render the main view renderSpritesheetPreview(); } function drawGrid() { if (!ctx.value || !canvas.value) return; ctx.value.strokeStyle = '#333'; ctx.value.lineWidth = 1 / zoomLevel.value; // Adjust line width based on zoom level // Calculate the visible area based on zoom level const visibleWidth = canvas.value.width / zoomLevel.value; const visibleHeight = canvas.value.height / zoomLevel.value; // Draw vertical lines - ensure pixel-perfect grid lines for (let x = 0; x <= visibleWidth; x += cellSize.width) { const pixelX = Math.floor(x) + 0.5; // Align to pixel boundary for crisp lines ctx.value.beginPath(); ctx.value.moveTo(pixelX, 0); ctx.value.lineTo(pixelX, visibleHeight); ctx.value.stroke(); } // Draw horizontal lines - ensure pixel-perfect grid lines for (let y = 0; y <= visibleHeight; y += cellSize.height) { const pixelY = Math.floor(y) + 0.5; // Align to pixel boundary for crisp lines ctx.value.beginPath(); ctx.value.moveTo(0, pixelY); ctx.value.lineTo(visibleWidth, pixelY); ctx.value.stroke(); } } function highlightSprite(spriteId: string) { if (!ctx.value || !canvas.value) return; const sprite = sprites.value.find(s => s.id === spriteId); if (!sprite) return; // Calculate the cell coordinates const cellX = Math.floor(sprite.x / cellSize.width); const cellY = Math.floor(sprite.y / cellSize.height); // Briefly flash the cell ctx.value.save(); ctx.value.fillStyle = 'rgba(0, 150, 255, 0.3)'; ctx.value.fillRect(cellX * cellSize.width, cellY * cellSize.height, cellSize.width, cellSize.height); ctx.value.restore(); // Reset after a short delay setTimeout(() => { renderSpritesheetPreview(); }, 500); } function clearAllSprites() { if (!canvas.value || !ctx.value) return; sprites.value = []; canvas.value.width = 400; canvas.value.height = 300; ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height); if (animation.canvas && animation.ctx) { animation.canvas.width = 200; animation.canvas.height = 200; animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height); } animation.currentFrame = 0; isModalOpen.value = false; } function downloadSpritesheet() { if (sprites.value.length === 0 || !canvas.value) { showNotification('No sprites to download', 'error'); return; } const tempCanvas = document.createElement('canvas'); tempCanvas.width = canvas.value.width; tempCanvas.height = canvas.value.height; const tempCtx = tempCanvas.getContext('2d'); if (!tempCtx) { showNotification('Failed to create download context', 'error'); return; } tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); // Ensure pixel art remains sharp in the downloaded file tempCtx.imageSmoothingEnabled = false; sprites.value.forEach(sprite => { // Use rounded coordinates for pixel-perfect rendering const x = Math.round(sprite.x); const y = Math.round(sprite.y); tempCtx.drawImage(sprite.img, x, y); }); const link = document.createElement('a'); link.download = 'spritesheet.png'; link.href = tempCanvas.toDataURL('image/png'); document.body.appendChild(link); link.click(); document.body.removeChild(link); showNotification('Spritesheet downloaded successfully'); } function startAnimation() { if (sprites.value.length === 0 || !animation.canvas) return; animation.isPlaying = true; animation.lastFrameTime = performance.now(); animation.manualUpdate = false; animation.canvas.width = cellSize.width; animation.canvas.height = cellSize.height; // Start the animation loop without resetting sprite offset animationLoop(); } function stopAnimation() { animation.isPlaying = false; if (animation.animationId) { cancelAnimationFrame(animation.animationId); animation.animationId = null; } } function renderAnimationFrame(frameIndex: number, showAllSprites = false, spriteOffset = { x: 0, y: 0 }) { if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return; if (animation.canvas.width !== cellSize.width || animation.canvas.height !== cellSize.height) { animation.canvas.width = cellSize.width; animation.canvas.height = cellSize.height; } animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height); // Draw background (transparent by default) animation.ctx.fillStyle = 'transparent'; animation.ctx.fillRect(0, 0, animation.canvas.width, animation.canvas.height); // Keep pixel art sharp animation.ctx.imageSmoothingEnabled = false; // If showAllSprites is enabled, draw all sprites with transparency if (showAllSprites && sprites.value.length > 1) { // Save the current context state animation.ctx.save(); // Set global alpha for background sprites animation.ctx.globalAlpha = 0.3; // Draw all sprites except the current one sprites.value.forEach((sprite, index) => { if (index !== frameIndex) { const spriteCellX = Math.floor(sprite.x / cellSize.width); const spriteCellY = Math.floor(sprite.y / cellSize.height); // Calculate precise offset for pixel-perfect rendering const spriteOffsetX = Math.round(sprite.x - spriteCellX * cellSize.width); const spriteOffsetY = Math.round(sprite.y - spriteCellY * cellSize.height); // Draw the sprite with transparency animation.ctx.drawImage(sprite.img, spriteOffsetX, spriteOffsetY); } }); // Restore the context to full opacity animation.ctx.restore(); } // Get the current sprite const currentSprite = sprites.value[frameIndex % sprites.value.length]; const cellX = Math.floor(currentSprite.x / cellSize.width); const cellY = Math.floor(currentSprite.y / cellSize.height); // Calculate the original position (without user offset) const originalOffsetX = Math.round(currentSprite.x - cellX * cellSize.width); const originalOffsetY = Math.round(currentSprite.y - cellY * cellSize.height); // Calculate precise offset for pixel-perfect rendering, including the user's drag offset const offsetX = originalOffsetX + spriteOffset.x; const offsetY = originalOffsetY + spriteOffset.y; // Draw the current sprite at full opacity at the new position animation.ctx.drawImage(currentSprite.img, offsetX, offsetY); // Draw border around the cell if enabled (only for preview, not included in download) if (previewBorder.enabled) { animation.ctx.strokeStyle = previewBorder.color; animation.ctx.lineWidth = previewBorder.width; // Use pixel-perfect coordinates for the border (0.5 offset for crisp lines) const x = 0.5; const y = 0.5; const width = animation.canvas.width - 1; const height = animation.canvas.height - 1; animation.ctx.strokeRect(x, y, width, height); } } // Store the current sprite offset for animation playback // We'll use a Map to store offsets for each frame, so they're preserved when switching frames const spriteOffsets = reactive(new Map()); // Current sprite offset is a reactive object that will be used for the current frame const currentSpriteOffset = reactive({ x: 0, y: 0 }); // Helper function to get the offset for a specific frame function getSpriteOffset(frameIndex: number) { if (!spriteOffsets.has(frameIndex)) { spriteOffsets.set(frameIndex, { x: 0, y: 0 }); } return spriteOffsets.get(frameIndex)!; } function animationLoop(timestamp?: number) { if (!animation.isPlaying) return; const currentTime = timestamp || performance.now(); const elapsed = currentTime - animation.lastFrameTime; const frameInterval = 1000 / animation.frameRate; if (elapsed >= frameInterval) { animation.lastFrameTime = currentTime; if (sprites.value.length > 0) { // Get the stored offset for the current frame const frameOffset = getSpriteOffset(animation.currentFrame); // Update the current offset for rendering currentSpriteOffset.x = frameOffset.x; currentSpriteOffset.y = frameOffset.y; // Render the current frame with its offset renderAnimationFrame(animation.currentFrame, false, frameOffset); // Move to the next frame animation.currentFrame = (animation.currentFrame + 1) % sprites.value.length; if (animation.slider) { animation.slider.value = animation.currentFrame.toString(); } } } animation.animationId = requestAnimationFrame(animationLoop); } function showNotification(message: string, type: 'success' | 'error' = 'success') { notification.message = message; notification.type = type; notification.isVisible = true; setTimeout(() => { notification.isVisible = false; }, 3000); } function zoomIn() { // Increase zoom level by 0.1, max 3.0 (300%) zoomLevel.value = Math.min(3.0, zoomLevel.value + 0.1); renderSpritesheetPreview(); showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`); } function zoomOut() { // Decrease zoom level by 0.1, min 0.5 (50%) zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1); renderSpritesheetPreview(); showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`); } function resetZoom() { // Reset to default zoom level (100%) zoomLevel.value = 1; renderSpritesheetPreview(); showNotification('Zoom reset to 100%'); } return { sprites, canvas, ctx, cellSize, columns, draggedSprite, dragOffset, isShiftPressed, isModalOpen, isSettingsModalOpen, isSpritesModalOpen, isHelpModalOpen, animation, notification, zoomLevel, previewBorder, currentSpriteOffset, spriteOffsets, getSpriteOffset, addSprites, updateCellSize, updateCanvasSize, autoArrangeSprites, renderSpritesheetPreview, drawGrid, highlightSprite, clearAllSprites, downloadSpritesheet, startAnimation, stopAnimation, renderAnimationFrame, showNotification, zoomIn, zoomOut, resetZoom, applyOffsetsToMainView, }; }