first commit
This commit is contained in:
514
src/components/SpriteCanvas.vue
Normal file
514
src/components/SpriteCanvas.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center">
|
||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2" @change="drawCanvas" />
|
||||
<label for="pixel-perfect" class="dark:text-gray-200">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" class="dark:text-gray-200">Allow moving between cells</label>
|
||||
</div>
|
||||
<!-- Add new checkbox for showing all sprites -->
|
||||
<div class="flex items-center">
|
||||
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" />
|
||||
<label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<select v-model="offsetAnchor" class="px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded">
|
||||
<option value="top-left">Top Left</option>
|
||||
<option value="top-right">Top Right</option>
|
||||
<option value="bottom-left">Bottom Left</option>
|
||||
<option value="bottom-right">Bottom Right</option>
|
||||
</select>
|
||||
<label class="dark:text-gray-200">Offset indicator position</label>
|
||||
<button @click="setNewOffsetBase" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded transition-colors">Set Current as Base</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<canvas ref="canvasRef" @mousedown="startDrag" @mousemove="drag" @mouseup="stopDrag" @mouseleave="stopDrag" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="stopDrag" class="w-full" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"></canvas>
|
||||
|
||||
<!-- Offset indicators overlay -->
|
||||
<div v-if="canvasRef" class="absolute top-0 left-0 pointer-events-none w-full h-full">
|
||||
<div v-for="pos in spritePositions" :key="pos.id" class="absolute" :style="getOffsetIndicatorStyle(pos)">
|
||||
<div class="text-xs bg-black/75 dark:bg-white/75 text-white dark:text-gray-900 px-1 rounded whitespace-nowrap">x:{{ pos.x }}, y:{{ pos.y }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed, onUnmounted } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
interface Sprite {
|
||||
id: string;
|
||||
img: HTMLImageElement;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface CellPosition {
|
||||
col: number;
|
||||
row: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||
(e: 'updateSpriteCell', id: string, newIndex: number): void;
|
||||
}>();
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// State for tracking drag operations
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const activeSpriteCellIndex = ref<number | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(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);
|
||||
|
||||
// Add these refs at the top with other refs
|
||||
const baseOffsets = ref<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
const showAllSprites = ref(false);
|
||||
|
||||
const spritePositions = computed(() => {
|
||||
if (!canvasRef.value) return [];
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
return props.sprites.map((sprite, index) => {
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
|
||||
// Calculate relative offset from base
|
||||
const baseOffset = baseOffsets.value[sprite.id] || { x: 0, y: 0 };
|
||||
const relativeX = sprite.x - baseOffset.x;
|
||||
const relativeY = sprite.y - baseOffset.y;
|
||||
|
||||
return {
|
||||
id: sprite.id,
|
||||
canvasX: col * maxWidth + sprite.x,
|
||||
canvasY: row * maxHeight + sprite.y,
|
||||
cellX: col * maxWidth,
|
||||
cellY: row * maxHeight,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
col,
|
||||
row,
|
||||
index,
|
||||
// Use relative offsets for display
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
props.sprites.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
|
||||
// Add some padding to ensure sprites have room to move
|
||||
return { maxWidth: maxWidth, maxHeight: maxHeight };
|
||||
};
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
|
||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
||||
if (clickedSprite) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = clickedSprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
|
||||
// Find the sprite's position to calculate offset from mouse to sprite origin
|
||||
const spritePosition = spritePositions.value.find(pos => pos.id === clickedSprite.id);
|
||||
if (spritePosition) {
|
||||
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) => {
|
||||
if (!isDragging.value || !activeSpriteId.value || !canvasRef.value || activeSpriteCellIndex.value === null) return;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
|
||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const spriteIndex = props.sprites.findIndex(s => s.id === activeSpriteId.value);
|
||||
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);
|
||||
if (!position) return;
|
||||
|
||||
// Calculate new position based on mouse position and initial click offset
|
||||
const newX = mouseX - position.cellX - dragOffsetX.value;
|
||||
const newY = mouseY - position.cellY - dragOffsetY.value;
|
||||
|
||||
// Constrain within cell boundaries and ensure integer positions
|
||||
const constrainedX = Math.floor(Math.max(0, Math.min(position.maxWidth - props.sprites[spriteIndex].width, newX)));
|
||||
const constrainedY = Math.floor(Math.max(0, Math.min(position.maxHeight - props.sprites[spriteIndex].height, newY)));
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, constrainedX, constrainedY);
|
||||
drawCanvas();
|
||||
};
|
||||
|
||||
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;
|
||||
activeSpriteId.value = null;
|
||||
activeSpriteCellIndex.value = null;
|
||||
currentHoverCell.value = null;
|
||||
highlightCell.value = null;
|
||||
ghostSprite.value = null;
|
||||
|
||||
// Redraw without highlights
|
||||
drawCanvas();
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
} as unknown as MouseEvent;
|
||||
startDrag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
} as unknown as MouseEvent;
|
||||
drag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const findSpriteAtPosition = (x: number, y: number) => {
|
||||
// Search in reverse order to get the topmost sprite first
|
||||
for (let i = spritePositions.value.length - 1; i >= 0; i--) {
|
||||
const pos = spritePositions.value[i];
|
||||
const sprite = props.sprites.find(s => s.id === pos.id);
|
||||
|
||||
if (!sprite) continue;
|
||||
|
||||
if (x >= pos.canvasX && x <= pos.canvasX + sprite.width && y >= pos.canvasY && y <= pos.canvasY + sprite.height) {
|
||||
return sprite;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const drawCanvas = () => {
|
||||
if (!canvasRef.value || !ctx.value) return;
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
// Set canvas size
|
||||
const rows = Math.ceil(props.sprites.length / props.columns);
|
||||
canvasRef.value.width = maxWidth * props.columns;
|
||||
canvasRef.value.height = maxHeight * rows;
|
||||
|
||||
// Clear canvas
|
||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||
|
||||
// Disable image smoothing based on pixel perfect setting
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
|
||||
// Draw background for each cell
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw cell background
|
||||
ctx.value.fillStyle = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
|
||||
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
|
||||
|
||||
// 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(cellX, cellY, maxWidth, maxHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If showing all sprites, draw all sprites with transparency in each cell
|
||||
if (showAllSprites.value) {
|
||||
for (let cellIndex = 0; cellIndex < props.sprites.length; cellIndex++) {
|
||||
const cellCol = cellIndex % props.columns;
|
||||
const cellRow = Math.floor(cellIndex / props.columns);
|
||||
const cellX = Math.floor(cellCol * maxWidth);
|
||||
const cellY = Math.floor(cellRow * maxHeight);
|
||||
|
||||
// Draw all sprites with transparency in this cell
|
||||
ctx.value.globalAlpha = 0.3;
|
||||
props.sprites.forEach((sprite, spriteIndex) => {
|
||||
if (spriteIndex !== cellIndex) {
|
||||
// Don't draw the cell's own sprite with transparency
|
||||
ctx.value?.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y);
|
||||
}
|
||||
});
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw sprites normally
|
||||
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 row = Math.floor(index / props.columns);
|
||||
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw sprite using integer positions for pixel-perfect rendering
|
||||
ctx.value?.drawImage(sprite.img, cellX + sprite.x, 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid lines on top of everything
|
||||
ctx.value.strokeStyle = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
||||
ctx.value.lineWidth = 1;
|
||||
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw grid lines
|
||||
ctx.value.strokeRect(cellX, cellY, maxWidth, maxHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
ctx.value = canvasRef.value.getContext('2d');
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
// Ensure we're using integer positions for pixel-perfect rendering
|
||||
props.sprites.forEach(sprite => {
|
||||
sprite.x = Math.floor(sprite.x);
|
||||
sprite.y = Math.floor(sprite.y);
|
||||
});
|
||||
|
||||
// Force a redraw with the correct image smoothing settings
|
||||
if (ctx.value) {
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
drawCanvas();
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.sprites, drawCanvas, { deep: true });
|
||||
watch(() => props.columns, drawCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawCanvas);
|
||||
watch(showAllSprites, drawCanvas);
|
||||
|
||||
// Add scale computed property
|
||||
const scale = computed(() => {
|
||||
if (!canvasRef.value) return 1;
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
return rect.width / canvasRef.value.width;
|
||||
});
|
||||
|
||||
// Add new ref for offset anchor
|
||||
const offsetAnchor = ref('top-left');
|
||||
|
||||
// Add new method to calculate indicator position
|
||||
const getOffsetIndicatorStyle = (pos: any) => {
|
||||
const baseX = pos.cellX * scale.value;
|
||||
const baseY = pos.cellY * scale.value;
|
||||
const cellWidth = pos.maxWidth * scale.value;
|
||||
const cellHeight = pos.maxHeight * scale.value;
|
||||
|
||||
switch (offsetAnchor.value) {
|
||||
case 'top-right':
|
||||
return {
|
||||
left: `${baseX + cellWidth}px`,
|
||||
top: `${baseY}px`,
|
||||
transform: 'translateX(-100%)',
|
||||
};
|
||||
case 'bottom-left':
|
||||
return {
|
||||
left: `${baseX}px`,
|
||||
top: `${baseY + cellHeight}px`,
|
||||
transform: 'translateY(-100%)',
|
||||
};
|
||||
case 'bottom-right':
|
||||
return {
|
||||
left: `${baseX + cellWidth}px`,
|
||||
top: `${baseY + cellHeight}px`,
|
||||
transform: 'translate(-100%, -100%)',
|
||||
};
|
||||
default: // top-left
|
||||
return {
|
||||
left: `${baseX}px`,
|
||||
top: `${baseY}px`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const setNewOffsetBase = () => {
|
||||
// Store current positions as new base positions
|
||||
props.sprites.forEach(sprite => {
|
||||
baseOffsets.value[sprite.id] = {
|
||||
x: Math.floor(sprite.x),
|
||||
y: Math.floor(sprite.y),
|
||||
};
|
||||
});
|
||||
|
||||
// Force redraw to update offset indicators
|
||||
drawCanvas();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add styles for offset indicators */
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user