466 lines
18 KiB
Vue
466 lines
18 KiB
Vue
<template>
|
|
<div class="spritesheet-preview w-full">
|
|
<!-- Controls Container -->
|
|
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:flex-wrap">
|
|
<!-- Playback Controls -->
|
|
<div class="flex items-center gap-2">
|
|
<div class="space-y-2">
|
|
<button @click="togglePlayback" class="flex items-center gap-1.5 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition-colors w-full cursor-pointer">
|
|
<span v-if="isPlaying" class="flex items-center gap-1.5">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
|
<path fill-rule="evenodd" d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z" clip-rule="evenodd" />
|
|
</svg>
|
|
<span class="hidden sm:inline">Pause</span>
|
|
</span>
|
|
<span v-else class="flex items-center gap-1.5">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
|
<path fill-rule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clip-rule="evenodd" />
|
|
</svg>
|
|
<span class="hidden sm:inline">Play</span>
|
|
</span>
|
|
</button>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<button @click="previousFrame" class="bg-gray-200 hover:bg-gray-300 p-2 rounded-md transition-colors duration-200 w-full cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 mx-auto">
|
|
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(90 12 12)" />
|
|
</svg>
|
|
</button>
|
|
<button @click="nextFrame" class="bg-gray-200 hover:bg-gray-300 p-2 rounded-md transition-colors duration-200 w-full cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 mx-auto">
|
|
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(-90 12 12)" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Checkboxes -->
|
|
<div class="flex flex-wrap gap-4 items-center">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" v-model="isDraggable" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300" />
|
|
<span class="text-sm whitespace-nowrap">Reposition</span>
|
|
</label>
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" v-model="showAllSprites" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300" />
|
|
<span class="text-sm whitespace-nowrap">Hide sprites</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Frame Controls -->
|
|
<div class="flex-1 min-w-[200px] space-y-6">
|
|
<!-- Frame Navigation -->
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium w-30">Frame {{ visibleFrameNumber }}/{{ visibleFramesCount }}</span>
|
|
<input type="range" min="0" :max="visibleFrames.length - 1" :value="visibleFrameIndex" @input="handleSliderInput" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
|
|
</div>
|
|
|
|
<!-- FPS Control -->
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium w-30">FPS: {{ fps }}</span>
|
|
<input type="range" min="1" max="60" v-model.number="fps" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500" />
|
|
</div>
|
|
|
|
<!-- Zoom Control -->
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium w-30">{{ Math.round(zoom * 100) }}%</span>
|
|
<input type="range" min="0.5" max="5" step="0.1" v-model.number="zoom" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showAllSprites" class="w-full mt-3">
|
|
<div class="flex items-center mb-2">
|
|
<label class="block text-sm font-medium text-gray-700">Select visible frames:</label>
|
|
<div class="ml-auto flex gap-2">
|
|
<button @click="showAllFrames" class="px-2 py-1 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors">Show All</button>
|
|
<button @click="hideAllFrames" class="px-2 py-1 text-sm bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-colors">Hide All</button>
|
|
</div>
|
|
</div>
|
|
<div class="w-full rounded-md border border-gray-300 shadow-sm focus-within:ring-1 focus-within:ring-blue-500 focus-within:border-blue-500">
|
|
<div class="max-h-[200px] overflow-y-auto">
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div v-for="(sprite, index) in props.sprites" :key="sprite.id" class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 cursor-pointer" @click="toggleHiddenFrame(index)">
|
|
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300" @click.stop @change="toggleHiddenFrame(index)" />
|
|
<div class="w-12 h-12 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
|
|
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" style="image-rendering: pixelated" />
|
|
</div>
|
|
<span class="text-sm">Frame {{ index + 1 }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 relative bg-gray-50 border border-gray-300 rounded-lg mb-6 overflow-auto min-h-[520px] shadow-sm hover:shadow-md transition-shadow duration-200">
|
|
<canvas
|
|
ref="previewCanvasRef"
|
|
@mousedown="startDrag"
|
|
@mousemove="drag"
|
|
@mouseup="stopDrag"
|
|
@mouseleave="stopDrag"
|
|
@touchstart="handleTouchStart"
|
|
@touchmove="handleTouchMove"
|
|
@touchend="stopDrag"
|
|
class="block"
|
|
:class="{ 'cursor-move': isDraggable }"
|
|
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }"
|
|
>
|
|
</canvas>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
|
|
|
|
interface Sprite {
|
|
id: string;
|
|
img: HTMLImageElement;
|
|
width: number;
|
|
height: number;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
sprites: Sprite[];
|
|
columns: number;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'updateSprite', id: string, x: number, y: number): void;
|
|
}>();
|
|
|
|
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
|
|
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
|
|
|
// Preview state
|
|
const currentFrameIndex = ref(0);
|
|
const isPlaying = ref(false);
|
|
const fps = ref(12);
|
|
const zoom = ref(1);
|
|
const isDraggable = ref(false);
|
|
const showAllSprites = ref(false);
|
|
const animationFrameId = ref<number | null>(null);
|
|
const lastFrameTime = ref(0);
|
|
|
|
// Dragging state
|
|
const isDragging = ref(false);
|
|
const activeSpriteId = ref<string | null>(null);
|
|
const dragStartX = ref(0);
|
|
const dragStartY = ref(0);
|
|
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
|
|
|
// Computed properties
|
|
const totalFrames = computed(() => props.sprites.length);
|
|
|
|
// Add this after other refs
|
|
const hiddenFrames = ref<number[]>([]);
|
|
const pixelPerfect = ref(true);
|
|
|
|
// Add these computed properties
|
|
const visibleFrames = computed(() => props.sprites.filter((_, index) => !hiddenFrames.value.includes(index)));
|
|
const visibleFramesCount = computed(() => visibleFrames.value.length);
|
|
const visibleFrameIndex = computed(() => {
|
|
return visibleFrames.value.findIndex((_, idx) => idx === visibleFrames.value.findIndex(s => s === props.sprites[currentFrameIndex.value]));
|
|
});
|
|
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
|
|
|
|
// Canvas drawing
|
|
const calculateMaxDimensions = () => {
|
|
let maxWidth = 0;
|
|
let maxHeight = 0;
|
|
|
|
props.sprites.forEach(sprite => {
|
|
maxWidth = Math.max(maxWidth, sprite.width);
|
|
maxHeight = Math.max(maxHeight, sprite.height);
|
|
});
|
|
|
|
return { maxWidth, maxHeight };
|
|
};
|
|
|
|
const drawPreviewCanvas = () => {
|
|
if (!previewCanvasRef.value || !ctx.value || props.sprites.length === 0) return;
|
|
|
|
const currentSprite = props.sprites[currentFrameIndex.value];
|
|
if (!currentSprite) return;
|
|
|
|
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
|
|
|
// Apply pixel art optimization consistently
|
|
ctx.value.imageSmoothingEnabled = !pixelPerfect.value;
|
|
|
|
// Set canvas size to just fit one sprite cell
|
|
previewCanvasRef.value.width = maxWidth;
|
|
previewCanvasRef.value.height = maxHeight;
|
|
|
|
// Clear canvas
|
|
ctx.value.clearRect(0, 0, previewCanvasRef.value.width, previewCanvasRef.value.height);
|
|
|
|
// Draw grid background (cell)
|
|
ctx.value.fillStyle = '#f9fafb';
|
|
ctx.value.fillRect(0, 0, maxWidth, maxHeight);
|
|
|
|
// Draw background grid (checkerboard pattern for transparency)
|
|
const cellSize = 10;
|
|
ctx.value.fillStyle = '#e5e7eb';
|
|
|
|
for (let x = 0; x < maxWidth; x += cellSize) {
|
|
for (let y = 0; y < maxHeight; y += cellSize) {
|
|
if ((x / cellSize + y / cellSize) % 2 === 0) {
|
|
ctx.value.fillRect(x, y, cellSize, cellSize);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep pixel art optimization consistent throughout all drawing operations
|
|
ctx.value.imageSmoothingEnabled = !pixelPerfect.value;
|
|
|
|
// Draw all sprites with transparency if enabled
|
|
if (showAllSprites.value && props.sprites.length > 1) {
|
|
ctx.value.globalAlpha = 0.3;
|
|
props.sprites.forEach((sprite, index) => {
|
|
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
|
|
ctx.value?.drawImage(sprite.img, sprite.x, sprite.y);
|
|
}
|
|
});
|
|
ctx.value.globalAlpha = 1.0;
|
|
}
|
|
|
|
// Draw current sprite
|
|
ctx.value.drawImage(currentSprite.img, currentSprite.x, currentSprite.y);
|
|
|
|
// Draw cell border
|
|
ctx.value.strokeStyle = '#e5e7eb';
|
|
ctx.value.lineWidth = 1;
|
|
ctx.value.strokeRect(0, 0, maxWidth, maxHeight);
|
|
};
|
|
|
|
// Animation control
|
|
const togglePlayback = () => {
|
|
isPlaying.value = !isPlaying.value;
|
|
|
|
if (isPlaying.value) {
|
|
startAnimation();
|
|
} else {
|
|
stopAnimation();
|
|
}
|
|
};
|
|
|
|
const startAnimation = () => {
|
|
lastFrameTime.value = performance.now();
|
|
animateFrame();
|
|
};
|
|
|
|
const stopAnimation = () => {
|
|
if (animationFrameId.value !== null) {
|
|
cancelAnimationFrame(animationFrameId.value);
|
|
animationFrameId.value = null;
|
|
}
|
|
};
|
|
|
|
const animateFrame = () => {
|
|
const now = performance.now();
|
|
const elapsed = now - lastFrameTime.value;
|
|
const frameTime = 1000 / fps.value;
|
|
|
|
if (elapsed >= frameTime) {
|
|
lastFrameTime.value = now - (elapsed % frameTime);
|
|
nextFrame();
|
|
}
|
|
|
|
animationFrameId.value = requestAnimationFrame(animateFrame);
|
|
};
|
|
|
|
const nextFrame = () => {
|
|
if (visibleFrames.value.length === 0) return;
|
|
|
|
const currentVisibleIndex = visibleFrameIndex.value;
|
|
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
|
|
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
|
|
drawPreviewCanvas();
|
|
};
|
|
|
|
const previousFrame = () => {
|
|
if (visibleFrames.value.length === 0) return;
|
|
|
|
const currentVisibleIndex = visibleFrameIndex.value;
|
|
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
|
|
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
|
|
drawPreviewCanvas();
|
|
};
|
|
|
|
// Add this method to handle slider input
|
|
const handleSliderInput = (event: Event) => {
|
|
const target = event.target as HTMLInputElement;
|
|
const index = parseInt(target.value);
|
|
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[index]);
|
|
};
|
|
|
|
// Drag functionality
|
|
const startDrag = (event: MouseEvent) => {
|
|
if (!isDraggable.value || !previewCanvasRef.value) return;
|
|
|
|
const rect = previewCanvasRef.value.getBoundingClientRect();
|
|
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
|
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
|
|
|
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
|
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
|
|
|
const sprite = props.sprites[currentFrameIndex.value];
|
|
|
|
// Check if click is on sprite
|
|
if (sprite && mouseX >= sprite.x && mouseX <= sprite.x + sprite.width && mouseY >= sprite.y && mouseY <= sprite.y + sprite.height) {
|
|
isDragging.value = true;
|
|
activeSpriteId.value = sprite.id;
|
|
dragStartX.value = mouseX;
|
|
dragStartY.value = mouseY;
|
|
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
|
}
|
|
};
|
|
|
|
const drag = (event: MouseEvent) => {
|
|
if (!isDragging.value || !activeSpriteId.value || !previewCanvasRef.value) return;
|
|
|
|
const rect = previewCanvasRef.value.getBoundingClientRect();
|
|
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
|
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
|
|
|
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
|
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
|
|
|
const deltaX = Math.round(mouseX - dragStartX.value);
|
|
const deltaY = Math.round(mouseY - dragStartY.value);
|
|
|
|
const sprite = props.sprites[currentFrameIndex.value];
|
|
if (!sprite || sprite.id !== activeSpriteId.value) return;
|
|
|
|
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
|
|
|
// Calculate new position with constraints and round to integers
|
|
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
|
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
|
|
|
// Constrain movement within cell
|
|
newX = Math.max(0, Math.min(maxWidth - sprite.width, newX));
|
|
newY = Math.max(0, Math.min(maxHeight - sprite.height, newY));
|
|
|
|
emit('updateSprite', activeSpriteId.value, newX, newY);
|
|
drawPreviewCanvas();
|
|
};
|
|
|
|
const stopDrag = () => {
|
|
isDragging.value = false;
|
|
activeSpriteId.value = null;
|
|
};
|
|
|
|
const handleTouchStart = (event: TouchEvent) => {
|
|
if (event.touches.length === 1) {
|
|
const touch = event.touches[0];
|
|
const mouseEvent = new MouseEvent('mousedown', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY,
|
|
});
|
|
startDrag(mouseEvent);
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (event: TouchEvent) => {
|
|
if (event.touches.length === 1) {
|
|
const touch = event.touches[0];
|
|
const mouseEvent = new MouseEvent('mousemove', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY,
|
|
});
|
|
drag(mouseEvent);
|
|
}
|
|
};
|
|
|
|
// Lifecycle hooks
|
|
onMounted(() => {
|
|
if (previewCanvasRef.value) {
|
|
ctx.value = previewCanvasRef.value.getContext('2d');
|
|
drawPreviewCanvas();
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopAnimation();
|
|
});
|
|
|
|
// Watchers
|
|
watch(() => props.sprites, drawPreviewCanvas, { deep: true });
|
|
watch(currentFrameIndex, drawPreviewCanvas);
|
|
watch(zoom, drawPreviewCanvas);
|
|
watch(isDraggable, drawPreviewCanvas);
|
|
watch(showAllSprites, drawPreviewCanvas);
|
|
watch(hiddenFrames, drawPreviewCanvas);
|
|
|
|
// Initial draw
|
|
if (props.sprites.length > 0) {
|
|
drawPreviewCanvas();
|
|
}
|
|
|
|
const toggleHiddenFrame = (index: number) => {
|
|
const currentIndex = hiddenFrames.value.indexOf(index);
|
|
if (currentIndex === -1) {
|
|
// Adding to hidden frames
|
|
hiddenFrames.value.push(index);
|
|
|
|
// If we're hiding the current frame, switch to the next visible frame
|
|
if (index === currentFrameIndex.value) {
|
|
const nextVisibleSprite = props.sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
|
|
if (nextVisibleSprite !== -1) {
|
|
currentFrameIndex.value = nextVisibleSprite;
|
|
}
|
|
}
|
|
} else {
|
|
// Removing from hidden frames
|
|
hiddenFrames.value.splice(currentIndex, 1);
|
|
}
|
|
|
|
// Force a redraw
|
|
drawPreviewCanvas();
|
|
};
|
|
|
|
const showAllFrames = () => {
|
|
hiddenFrames.value = [];
|
|
drawPreviewCanvas();
|
|
};
|
|
|
|
const hideAllFrames = () => {
|
|
hiddenFrames.value = props.sprites.map((_, index) => index);
|
|
// Keep at least one frame visible
|
|
if (hiddenFrames.value.length > 0) {
|
|
hiddenFrames.value.splice(currentFrameIndex.value, 1);
|
|
}
|
|
drawPreviewCanvas();
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Custom styling for range inputs */
|
|
input[type='range']::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: #3b82f6;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input[type='range']::-moz-range-thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: #3b82f6;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
</style>
|