first commit

This commit is contained in:
root
2025-05-05 08:52:16 +02:00
commit e3205d500d
41 changed files with 7772 additions and 0 deletions

View 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>