spritesheet-generator/src/composables/useSpritesheetStore.ts
2025-04-04 02:55:56 +02:00

669 lines
21 KiB
TypeScript

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<Sprite[]>([]);
const canvas = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
const cellSize = reactive<CellSize>({ width: 0, height: 0 });
const columns = ref(4); // Default number of columns
const draggedSprite = ref<Sprite | null>(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<AnimationState>({
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<string>();
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<number, { x: number; y: number }>());
// 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,
};
}