Init
This commit is contained in:
commit
a989c8719f
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
33
README.md
Normal file
33
README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# spritesheetgen
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
14
index.html
Normal file
14
index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Spritesheet generator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
3680
package-lock.json
generated
Normal file
3680
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "spritesheetgen",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.1",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/node": "^22.13.14",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"prettier": "3.5.3",
|
||||
"tailwindcss": "^4.1.1",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-vue-devtools": "^7.7.2",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
32
src/App.vue
Normal file
32
src/App.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 text-gray-200 font-sans">
|
||||
<app-header @toggle-help="showHelpModal" />
|
||||
<div class="max-w-7xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<sidebar class="lg:col-span-1" />
|
||||
<main-content class="lg:col-span-3" />
|
||||
</div>
|
||||
<preview-modal ref="previewModalRef" />
|
||||
<notification />
|
||||
<help-button @show-help="showHelpModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import AppHeader from './components/AppHeader.vue';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import MainContent from './components/MainContent.vue';
|
||||
import PreviewModal from './components/PreviewModal.vue';
|
||||
import Notification from './components/Notification.vue';
|
||||
import HelpButton from './components/HelpButton.vue';
|
||||
|
||||
const previewModalRef = ref<InstanceType<typeof PreviewModal> | null>(null);
|
||||
|
||||
const showHelpModal = () => {
|
||||
alert('Keyboard Shortcuts:\n\n' +
|
||||
'Shift + Drag: Fine-tune sprite position\n' +
|
||||
'Space: Play/Pause animation\n' +
|
||||
'Esc: Close preview modal\n' +
|
||||
'Arrow Keys: Navigate frames when paused');
|
||||
};
|
||||
</script>
|
1
src/assets/main.css
Normal file
1
src/assets/main.css
Normal file
@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
26
src/components/AppHeader.vue
Normal file
26
src/components/AppHeader.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<header class="flex items-center justify-between bg-gray-800 p-3 shadow-md sticky top-0 z-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-gamepad text-blue-500 text-2xl"></i>
|
||||
<h1 class="text-xl font-semibold text-gray-200">Noxious Spritesheet Creator</h1>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="$emit('toggleHelp')"
|
||||
class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors"
|
||||
title="Keyboard Shortcuts"
|
||||
>
|
||||
<i class="fas fa-keyboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppHeader',
|
||||
emits: ['toggleHelp']
|
||||
});
|
||||
</script>
|
133
src/components/DropZone.vue
Normal file
133
src/components/DropZone.vue
Normal file
@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div
|
||||
@click="openFileDialog"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
class="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center cursor-pointer transition-all"
|
||||
:class="{'border-blue-500 bg-blue-500 bg-opacity-5': isDragOver}"
|
||||
>
|
||||
<i class="fas fa-cloud-upload-alt text-blue-500 text-3xl mb-4"></i>
|
||||
<p class="text-gray-400">
|
||||
Drag & drop sprite images here<br>or click to select files
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { Sprite } from '../composables/useSpritesheetStore';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DropZone',
|
||||
emits: ['files-uploaded'],
|
||||
setup(props, { emit }) {
|
||||
const store = useSpritesheetStore();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isDragOver = ref(false);
|
||||
|
||||
const openFileDialog = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click();
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = () => {
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const onDragLeave = () => {
|
||||
isDragOver.value = false;
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragOver.value = false;
|
||||
if (e.dataTransfer?.files.length) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
handleFiles(input.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = async (files: FileList) => {
|
||||
const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
store.showNotification('Please upload image files only', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newSprites: Sprite[] = [];
|
||||
|
||||
for (let i = 0; i < imageFiles.length; i++) {
|
||||
const file = imageFiles[i];
|
||||
try {
|
||||
const sprite = await createSpriteFromFile(file, i);
|
||||
newSprites.push(sprite);
|
||||
} catch (error) {
|
||||
console.error('Error loading sprite:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (newSprites.length > 0) {
|
||||
store.addSprites(newSprites);
|
||||
emit('files-uploaded', newSprites);
|
||||
store.showNotification(`Added ${newSprites.length} sprites successfully`);
|
||||
}
|
||||
};
|
||||
|
||||
const createSpriteFromFile = (file: File, index: number): Promise<Sprite> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
img,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
name: file.name,
|
||||
id: `sprite-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
uploadOrder: index
|
||||
});
|
||||
};
|
||||
|
||||
img.onerror = reject;
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
fileInput,
|
||||
isDragOver,
|
||||
openFileDialog,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onFileChange
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
17
src/components/HelpButton.vue
Normal file
17
src/components/HelpButton.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<button
|
||||
@click="$emit('showHelp')"
|
||||
class="fixed bottom-5 right-5 w-12 h-12 bg-blue-500 text-white rounded-full flex items-center justify-center text-xl shadow-lg cursor-pointer transition-all hover:bg-blue-600 hover:-translate-y-1 z-40"
|
||||
>
|
||||
<i class="fas fa-question"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'HelpButton',
|
||||
emits: ['showHelp']
|
||||
});
|
||||
</script>
|
228
src/components/MainContent.vue
Normal file
228
src/components/MainContent.vue
Normal file
@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden w-full">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-th-large text-blue-500"></i>
|
||||
<span>Spritesheet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="relative overflow-auto bg-gray-700 rounded border border-gray-600">
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
class="block mx-auto"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
v-if="isTooltipVisible"
|
||||
:style="tooltipStyle"
|
||||
class="absolute bg-gray-700 text-gray-200 px-3 py-2 rounded text-xs z-50 pointer-events-none shadow-md border border-gray-600"
|
||||
>
|
||||
{{ tooltipText }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted, computed, onBeforeUnmount } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainContent',
|
||||
setup() {
|
||||
const store = useSpritesheetStore();
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
// Tooltip state
|
||||
const isTooltipVisible = ref(false);
|
||||
const tooltipText = ref('');
|
||||
const tooltipPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltipPosition.value.x + 15}px`,
|
||||
top: `${tooltipPosition.value.y + 15}px`
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasEl.value) {
|
||||
store.canvas.value = canvasEl.value;
|
||||
store.ctx.value = canvasEl.value.getContext('2d');
|
||||
|
||||
// Initialize canvas size
|
||||
canvasEl.value.width = 400;
|
||||
canvasEl.value.height = 300;
|
||||
|
||||
// Set up checkerboard background pattern
|
||||
setupCheckerboardPattern();
|
||||
|
||||
setupCanvasEvents();
|
||||
|
||||
// Setup keyboard events for modifiers
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
|
||||
if (canvasEl.value) {
|
||||
canvasEl.value.removeEventListener('mousedown', handleMouseDown);
|
||||
canvasEl.value.removeEventListener('mousemove', handleMouseMove);
|
||||
canvasEl.value.removeEventListener('mouseup', handleMouseUp);
|
||||
canvasEl.value.removeEventListener('mouseout', handleMouseOut);
|
||||
}
|
||||
});
|
||||
|
||||
const setupCheckerboardPattern = () => {
|
||||
if (!canvasEl.value) return;
|
||||
|
||||
// This will be done with CSS using Tailwind's bg utilities
|
||||
canvasEl.value.style.backgroundImage = `
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%)
|
||||
`;
|
||||
canvasEl.value.style.backgroundSize = '20px 20px';
|
||||
canvasEl.value.style.backgroundPosition = '0 0, 0 10px, 10px -10px, -10px 0px';
|
||||
};
|
||||
|
||||
const setupCanvasEvents = () => {
|
||||
if (!canvasEl.value) return;
|
||||
|
||||
canvasEl.value.addEventListener('mousedown', handleMouseDown);
|
||||
canvasEl.value.addEventListener('mousemove', handleMouseMove);
|
||||
canvasEl.value.addEventListener('mouseup', handleMouseUp);
|
||||
canvasEl.value.addEventListener('mouseout', handleMouseOut);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (!canvasEl.value || store.sprites.value.length === 0) return;
|
||||
|
||||
const rect = canvasEl.value.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Find which sprite was clicked
|
||||
for (let i = store.sprites.value.length - 1; i >= 0; i--) {
|
||||
const sprite = store.sprites.value[i];
|
||||
if (
|
||||
x >= sprite.x &&
|
||||
x <= sprite.x + store.cellSize.width &&
|
||||
y >= sprite.y &&
|
||||
y <= sprite.y + store.cellSize.height
|
||||
) {
|
||||
store.draggedSprite.value = sprite;
|
||||
store.dragOffset.x = x - sprite.x;
|
||||
store.dragOffset.y = y - sprite.y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!canvasEl.value) return;
|
||||
|
||||
const rect = canvasEl.value.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Update tooltip
|
||||
const cellX = Math.floor(x / store.cellSize.width);
|
||||
const cellY = Math.floor(y / store.cellSize.height);
|
||||
|
||||
if (canvasEl.value &&
|
||||
cellX >= 0 && cellX < canvasEl.value.width / store.cellSize.width &&
|
||||
cellY >= 0 && cellY < canvasEl.value.height / store.cellSize.height) {
|
||||
isTooltipVisible.value = true;
|
||||
tooltipText.value = `Cell: (${cellX}, ${cellY})`;
|
||||
tooltipPosition.value.x = e.clientX;
|
||||
tooltipPosition.value.y = e.clientY;
|
||||
} else {
|
||||
isTooltipVisible.value = false;
|
||||
}
|
||||
|
||||
// Move the sprite if we're dragging one
|
||||
if (store.draggedSprite.value) {
|
||||
if (store.isShiftPressed.value) {
|
||||
// Free positioning within the cell bounds when shift is pressed
|
||||
// First determine which cell we're in
|
||||
const cellX = Math.floor(store.draggedSprite.value.x / store.cellSize.width);
|
||||
const cellY = Math.floor(store.draggedSprite.value.y / store.cellSize.height);
|
||||
|
||||
// Calculate new position with constraints to stay within the cell
|
||||
const newX = x - store.dragOffset.x;
|
||||
const newY = y - store.dragOffset.y;
|
||||
|
||||
// Calculate cell boundaries
|
||||
const cellLeft = cellX * store.cellSize.width;
|
||||
const cellTop = cellY * store.cellSize.height;
|
||||
const cellRight = cellLeft + store.cellSize.width - store.draggedSprite.value.width;
|
||||
const cellBottom = cellTop + store.cellSize.height - store.draggedSprite.value.height;
|
||||
|
||||
// Constrain position to stay within the cell
|
||||
store.draggedSprite.value.x = Math.max(cellLeft, Math.min(newX, cellRight));
|
||||
store.draggedSprite.value.y = Math.max(cellTop, Math.min(newY, cellBottom));
|
||||
} else {
|
||||
// Calculate new position based on grid cells (snap to grid)
|
||||
const newCellX = Math.floor((x - store.dragOffset.x) / store.cellSize.width);
|
||||
const newCellY = Math.floor((y - store.dragOffset.y) / store.cellSize.height);
|
||||
|
||||
// Make sure we stay within bounds
|
||||
if (canvasEl.value) {
|
||||
const maxCellX = Math.floor(canvasEl.value.width / store.cellSize.width) - 1;
|
||||
const maxCellY = Math.floor(canvasEl.value.height / store.cellSize.height) - 1;
|
||||
|
||||
const boundedCellX = Math.max(0, Math.min(newCellX, maxCellX));
|
||||
const boundedCellY = Math.max(0, Math.min(newCellY, maxCellY));
|
||||
|
||||
// Update sprite position to snap to grid
|
||||
store.draggedSprite.value.x = boundedCellX * store.cellSize.width;
|
||||
store.draggedSprite.value.y = boundedCellY * store.cellSize.height;
|
||||
}
|
||||
}
|
||||
|
||||
store.renderSpritesheetPreview();
|
||||
|
||||
// Update animation preview if paused
|
||||
if (!store.animation.isPlaying && store.sprites.value.length > 0 && store.isModalOpen.value) {
|
||||
store.renderAnimationFrame(store.animation.currentFrame);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
store.draggedSprite.value = null;
|
||||
};
|
||||
|
||||
const handleMouseOut = () => {
|
||||
store.draggedSprite.value = null;
|
||||
isTooltipVisible.value = false;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
store.isShiftPressed.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
store.isShiftPressed.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
canvasEl,
|
||||
isTooltipVisible,
|
||||
tooltipText,
|
||||
tooltipStyle
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
49
src/components/Notification.vue
Normal file
49
src/components/Notification.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-5 right-5 bg-gray-700 text-gray-200 p-4 rounded shadow-lg z-50 flex items-center gap-3 transform transition-all duration-300"
|
||||
:class="{
|
||||
'translate-y-0 opacity-100': notification.isVisible,
|
||||
'translate-y-24 opacity-0': !notification.isVisible,
|
||||
'border-l-4 border-green-500': notification.type === 'success',
|
||||
'border-l-4 border-red-500': notification.type === 'error'
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="text-xl"
|
||||
:class="{
|
||||
'fas fa-check-circle text-green-500': notification.type === 'success',
|
||||
'fas fa-exclamation-circle text-red-500': notification.type === 'error'
|
||||
}"
|
||||
></i>
|
||||
<span>{{ notification.message }}</span>
|
||||
<button
|
||||
@click="closeNotification"
|
||||
class="ml-2 text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Notification',
|
||||
setup() {
|
||||
const store = useSpritesheetStore();
|
||||
|
||||
const notification = computed(() => store.notification);
|
||||
|
||||
const closeNotification = () => {
|
||||
store.notification.isVisible = false;
|
||||
};
|
||||
|
||||
return {
|
||||
notification,
|
||||
closeNotification
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
243
src/components/PreviewModal.vue
Normal file
243
src/components/PreviewModal.vue
Normal file
@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 transition-all duration-300"
|
||||
:class="{ 'opacity-0 invisible': !isModalOpen, 'opacity-100 visible': isModalOpen }"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800 rounded-lg max-w-4xl max-h-[90vh] overflow-auto shadow-lg transform transition-transform duration-300"
|
||||
:class="{ '-translate-y-5': !isModalOpen, 'translate-y-0': isModalOpen }"
|
||||
>
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-film text-blue-500"></i>
|
||||
<span>Animation Preview</span>
|
||||
</div>
|
||||
<button @click="closeModal" class="text-gray-400 hover:text-gray-200 text-xl">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="startAnimation"
|
||||
:disabled="sprites.length === 0"
|
||||
:class="{'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': !animation.isPlaying}"
|
||||
class="flex items-center gap-2 border rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i class="fas fa-play"></i> Play
|
||||
</button>
|
||||
<button
|
||||
@click="stopAnimation"
|
||||
:disabled="sprites.length === 0"
|
||||
:class="{'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': animation.isPlaying}"
|
||||
class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
|
||||
>
|
||||
<i class="fas fa-pause"></i> Pause
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 flex-grow">
|
||||
<div class="flex justify-between text-sm text-gray-400">
|
||||
<label for="frame-slider">Frame:</label>
|
||||
<span>{{ currentFrameDisplay }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id="frame-slider"
|
||||
v-model="currentFrame"
|
||||
:min="0"
|
||||
:max="Math.max(0, sprites.length - 1)"
|
||||
class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer"
|
||||
@input="handleFrameChange"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 flex-grow">
|
||||
<div class="flex justify-between text-sm text-gray-400">
|
||||
<label for="framerate">Frame Rate:</label>
|
||||
<span>{{ animation.frameRate }} FPS</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id="framerate"
|
||||
v-model.number="animation.frameRate"
|
||||
min="1"
|
||||
max="30"
|
||||
class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer"
|
||||
@input="handleFrameRateChange"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center bg-gray-700 p-6 rounded mb-6">
|
||||
<canvas ref="animCanvas" class="block"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PreviewModal',
|
||||
setup() {
|
||||
const store = useSpritesheetStore();
|
||||
const animCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const isModalOpen = computed(() => store.isModalOpen.value);
|
||||
const sprites = computed(() => store.sprites.value);
|
||||
const animation = computed(() => store.animation);
|
||||
|
||||
const currentFrame = ref(0);
|
||||
|
||||
const currentFrameDisplay = computed(() => {
|
||||
const totalFrames = Math.max(1, sprites.value.length);
|
||||
const frame = Math.min(currentFrame.value + 1, totalFrames);
|
||||
return `${frame} / ${totalFrames}`;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (animCanvas.value) {
|
||||
store.animation.canvas = animCanvas.value;
|
||||
store.animation.ctx = animCanvas.value.getContext('2d');
|
||||
|
||||
// Initialize canvas size
|
||||
animCanvas.value.width = 200;
|
||||
animCanvas.value.height = 200;
|
||||
|
||||
// Setup keyboard shortcuts for the modal
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isModalOpen.value) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
} else if (e.key === ' ' || e.key === 'Spacebar') {
|
||||
// Toggle play/pause
|
||||
if (animation.value.isPlaying) {
|
||||
stopAnimation();
|
||||
} else if (sprites.value.length > 0) {
|
||||
startAnimation();
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) {
|
||||
// Next frame
|
||||
currentFrame.value = (currentFrame.value + 1) % sprites.value.length;
|
||||
updateFrame();
|
||||
} else if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) {
|
||||
// Previous frame
|
||||
currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length;
|
||||
updateFrame();
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
if (sprites.value.length === 0) {
|
||||
store.showNotification('Please add sprites first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
store.isModalOpen.value = true;
|
||||
|
||||
// Show the current frame
|
||||
if (!animation.value.isPlaying && sprites.value.length > 0) {
|
||||
store.renderAnimationFrame(currentFrame.value);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
store.isModalOpen.value = false;
|
||||
|
||||
// Stop animation if it's playing
|
||||
if (animation.value.isPlaying) {
|
||||
stopAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const startAnimation = () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
store.startAnimation();
|
||||
};
|
||||
|
||||
const stopAnimation = () => {
|
||||
store.stopAnimation();
|
||||
};
|
||||
|
||||
const handleFrameChange = () => {
|
||||
// Stop any running animation
|
||||
if (animation.value.isPlaying) {
|
||||
stopAnimation();
|
||||
}
|
||||
|
||||
updateFrame();
|
||||
};
|
||||
|
||||
const updateFrame = () => {
|
||||
animation.value.currentFrame = currentFrame.value;
|
||||
animation.value.manualUpdate = true;
|
||||
store.renderAnimationFrame(currentFrame.value);
|
||||
};
|
||||
|
||||
const handleFrameRateChange = () => {
|
||||
// If animation is currently playing, restart it with the new frame rate
|
||||
if (animation.value.isPlaying) {
|
||||
stopAnimation();
|
||||
startAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
// Keep currentFrame in sync with animation.currentFrame
|
||||
watch(() => animation.value.currentFrame, (newVal) => {
|
||||
currentFrame.value = newVal;
|
||||
});
|
||||
|
||||
return {
|
||||
animCanvas,
|
||||
isModalOpen,
|
||||
sprites,
|
||||
animation,
|
||||
currentFrame,
|
||||
currentFrameDisplay,
|
||||
openModal,
|
||||
closeModal,
|
||||
startAnimation,
|
||||
stopAnimation,
|
||||
handleFrameChange,
|
||||
handleFrameRateChange
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #0096ff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #0096ff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
143
src/components/Sidebar.vue
Normal file
143
src/components/Sidebar.vue
Normal file
@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Upload Card -->
|
||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-upload text-blue-500"></i>
|
||||
<span>Upload Sprites</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<drop-zone @files-uploaded="handleUpload" />
|
||||
|
||||
<div class="bg-blue-500 bg-opacity-10 border-l-4 border-blue-500 p-4 mt-6 rounded-r">
|
||||
<p>Container size will adjust to fit the largest sprite. All sprites will be placed in cells of the same size.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sprites Card -->
|
||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-images text-blue-500"></i>
|
||||
<span>Sprites</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<sprite-list :sprites="sprites" @sprite-clicked="handleSpriteClick" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools Card -->
|
||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-tools text-blue-500"></i>
|
||||
<span>Tools</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<button
|
||||
@click="autoArrangeSprites"
|
||||
:disabled="sprites.length === 0"
|
||||
class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
|
||||
>
|
||||
<i class="fas fa-th"></i> Auto Arrange
|
||||
</button>
|
||||
<button
|
||||
@click="openPreviewModal"
|
||||
:disabled="sprites.length === 0"
|
||||
class="flex items-center gap-2 bg-blue-500 border border-blue-500 text-white rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:bg-blue-600 hover:border-blue-600"
|
||||
>
|
||||
<i class="fas fa-play"></i> Preview Animation
|
||||
</button>
|
||||
<button
|
||||
@click="downloadSpritesheet"
|
||||
:disabled="sprites.length === 0"
|
||||
class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
|
||||
>
|
||||
<i class="fas fa-download"></i> Download
|
||||
</button>
|
||||
<button
|
||||
@click="confirmClearAll"
|
||||
:disabled="sprites.length === 0"
|
||||
class="flex items-center gap-2 bg-red-600 border border-red-600 text-white rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:bg-red-700 hover:border-red-700"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Shift</kbd>
|
||||
<span>Fine-tune position</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Space</kbd>
|
||||
<span>Play/Pause animation</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Esc</kbd>
|
||||
<span>Close preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import DropZone from './DropZone.vue';
|
||||
import SpriteList from './SpriteList.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Sidebar',
|
||||
components: {
|
||||
DropZone,
|
||||
SpriteList
|
||||
},
|
||||
setup() {
|
||||
const store = useSpritesheetStore();
|
||||
|
||||
const handleUpload = () => {
|
||||
// The dropzone component handles adding sprites to the store
|
||||
// This is just for event handling if needed
|
||||
};
|
||||
|
||||
const handleSpriteClick = (spriteId: string) => {
|
||||
store.highlightSprite(spriteId);
|
||||
};
|
||||
|
||||
const openPreviewModal = () => {
|
||||
if (store.sprites.value.length === 0) {
|
||||
store.showNotification('Please add sprites first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
store.isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const confirmClearAll = () => {
|
||||
if (confirm('Are you sure you want to clear all sprites?')) {
|
||||
store.clearAllSprites();
|
||||
store.showNotification('All sprites cleared');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
sprites: computed(() => store.sprites.value),
|
||||
autoArrangeSprites: store.autoArrangeSprites,
|
||||
downloadSpritesheet: store.downloadSpritesheet,
|
||||
confirmClearAll,
|
||||
handleUpload,
|
||||
handleSpriteClick,
|
||||
openPreviewModal
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
48
src/components/SpriteList.vue
Normal file
48
src/components/SpriteList.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div v-if="sprites.length === 0" class="text-center text-gray-400 py-8">
|
||||
No sprites uploaded yet
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 max-h-72 overflow-y-auto pr-2">
|
||||
<div
|
||||
v-for="(sprite, index) in sprites"
|
||||
:key="sprite.id"
|
||||
@click="$emit('sprite-clicked', sprite.id)"
|
||||
class="border border-gray-600 rounded bg-gray-700 p-2 text-center transition-all cursor-pointer hover:border-blue-500 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<img
|
||||
:src="sprite.img.src"
|
||||
:alt="sprite.name"
|
||||
class="max-w-full max-h-16 mx-auto mb-2 bg-black bg-opacity-20 rounded"
|
||||
>
|
||||
<div class="text-xs text-gray-400 truncate">
|
||||
{{ index + 1 }}. {{ truncateName(sprite.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import { type Sprite } from '../composables/useSpritesheetStore';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SpriteList',
|
||||
props: {
|
||||
sprites: {
|
||||
type: Array as PropType<Sprite[]>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['sprite-clicked'],
|
||||
setup() {
|
||||
const truncateName = (name: string) => {
|
||||
return name.length > 10 ? `${name.substring(0, 10)}...` : name;
|
||||
};
|
||||
|
||||
return {
|
||||
truncateName
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
12
src/composables/counter.ts
Normal file
12
src/composables/counter.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
343
src/composables/useSpritesheetStore.ts
Normal file
343
src/composables/useSpritesheetStore.ts
Normal file
@ -0,0 +1,343 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function useSpritesheetStore() {
|
||||
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 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[]) {
|
||||
sprites.value.push(...newSprites);
|
||||
sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder);
|
||||
updateCellSize();
|
||||
autoArrangeSprites();
|
||||
}
|
||||
|
||||
function updateCellSize() {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
|
||||
cellSize.width = maxWidth;
|
||||
cellSize.height = maxHeight;
|
||||
|
||||
updateCanvasSize();
|
||||
}
|
||||
|
||||
function updateCanvasSize() {
|
||||
if (!canvas.value || sprites.value.length === 0) return;
|
||||
|
||||
const totalSprites = sprites.value.length;
|
||||
const cols = columns.value;
|
||||
const rows = Math.ceil(totalSprites / cols);
|
||||
|
||||
canvas.value.width = cols * cellSize.width;
|
||||
canvas.value.height = rows * cellSize.height;
|
||||
}
|
||||
|
||||
function autoArrangeSprites() {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
renderSpritesheetPreview();
|
||||
|
||||
if (!animation.isPlaying && animation.manualUpdate && isModalOpen.value) {
|
||||
renderAnimationFrame(animation.currentFrame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpritesheetPreview(showGrid = true) {
|
||||
if (!ctx.value || !canvas.value || sprites.value.length === 0) return;
|
||||
|
||||
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
|
||||
if (showGrid) {
|
||||
drawGrid();
|
||||
}
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
ctx.value!.drawImage(
|
||||
sprite.img,
|
||||
sprite.x,
|
||||
sprite.y
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function drawGrid() {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
ctx.value.strokeStyle = '#333';
|
||||
ctx.value.lineWidth = 1;
|
||||
|
||||
// Draw vertical lines
|
||||
for (let x = 0; x <= canvas.value.width; x += cellSize.width) {
|
||||
ctx.value.beginPath();
|
||||
ctx.value.moveTo(x, 0);
|
||||
ctx.value.lineTo(x, canvas.value.height);
|
||||
ctx.value.stroke();
|
||||
}
|
||||
|
||||
// Draw horizontal lines
|
||||
for (let y = 0; y <= canvas.value.height; y += cellSize.height) {
|
||||
ctx.value.beginPath();
|
||||
ctx.value.moveTo(0, y);
|
||||
ctx.value.lineTo(canvas.value.width, y);
|
||||
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);
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
tempCtx.drawImage(
|
||||
sprite.img,
|
||||
sprite.x,
|
||||
sprite.y
|
||||
);
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = 'noxious-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;
|
||||
|
||||
animationLoop();
|
||||
}
|
||||
|
||||
function stopAnimation() {
|
||||
animation.isPlaying = false;
|
||||
|
||||
if (animation.animationId) {
|
||||
cancelAnimationFrame(animation.animationId);
|
||||
animation.animationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnimationFrame(frameIndex: number) {
|
||||
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);
|
||||
|
||||
const currentSprite = sprites.value[frameIndex % sprites.value.length];
|
||||
|
||||
const cellX = Math.floor(currentSprite.x / cellSize.width);
|
||||
const cellY = Math.floor(currentSprite.y / cellSize.height);
|
||||
|
||||
const offsetX = currentSprite.x - (cellX * cellSize.width);
|
||||
const offsetY = currentSprite.y - (cellY * cellSize.height);
|
||||
|
||||
animation.ctx.drawImage(
|
||||
currentSprite.img,
|
||||
offsetX,
|
||||
offsetY
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
renderAnimationFrame(animation.currentFrame);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
sprites,
|
||||
canvas,
|
||||
ctx,
|
||||
cellSize,
|
||||
columns,
|
||||
draggedSprite,
|
||||
dragOffset,
|
||||
isShiftPressed,
|
||||
isModalOpen,
|
||||
animation,
|
||||
notification,
|
||||
addSprites,
|
||||
updateCellSize,
|
||||
updateCanvasSize,
|
||||
autoArrangeSprites,
|
||||
renderSpritesheetPreview,
|
||||
drawGrid,
|
||||
highlightSprite,
|
||||
clearAllSprites,
|
||||
downloadSpritesheet,
|
||||
startAnimation,
|
||||
stopAnimation,
|
||||
renderAnimationFrame,
|
||||
showNotification
|
||||
};
|
||||
}
|
11
src/main.ts
Normal file
11
src/main.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
|
||||
app.mount('#app')
|
31
tailwind.config.js
Normal file
31
tailwind.config.js
Normal file
@ -0,0 +1,31 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
gray: {
|
||||
900: '#121212', // bg-primary
|
||||
800: '#1e1e1e', // bg-secondary
|
||||
700: '#252525', // bg-tertiary
|
||||
600: '#333333', // border
|
||||
400: '#a0a0a0', // text-secondary
|
||||
200: '#e0e0e0', // text-primary
|
||||
},
|
||||
blue: {
|
||||
500: '#0096ff', // accent
|
||||
600: '#0077cc', // accent-hover
|
||||
},
|
||||
red: {
|
||||
500: '#e53935', // danger
|
||||
600: '#c62828', // danger-hover
|
||||
},
|
||||
green: {
|
||||
500: '#43a047', // success
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
12
tsconfig.app.json
Normal file
12
tsconfig.app.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
20
vite.config.ts
Normal file
20
vite.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user