From 32ca61cc505028d946d0a7cb3f7db6052e9c7353 Mon Sep 17 00:00:00 2001 From: Dennis Postma <dennis@directonline.io> Date: Sun, 16 Mar 2025 02:51:17 +0100 Subject: [PATCH] Updated sprite editor component --- .../partials/sprite/SpriteDetails.vue | 1 + .../partials/sprite/partials/SpriteEditor.vue | 262 +++++++++++++++--- 2 files changed, 217 insertions(+), 46 deletions(-) diff --git a/src/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue b/src/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue index feedb57..da0653a 100644 --- a/src/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue +++ b/src/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue @@ -34,6 +34,7 @@ <SpriteEditor v-for="[actionId, editorData] in Array.from(openEditors.entries())" :key="actionId" + :sprite="selectedSprite!" :sprites="editorData.action.sprites" :frame-rate="editorData.action.frameRate" :is-modal-open="editorData.isOpen" diff --git a/src/components/gameMaster/assetManager/partials/sprite/partials/SpriteEditor.vue b/src/components/gameMaster/assetManager/partials/sprite/partials/SpriteEditor.vue index a9960e4..f3324ed 100644 --- a/src/components/gameMaster/assetManager/partials/sprite/partials/SpriteEditor.vue +++ b/src/components/gameMaster/assetManager/partials/sprite/partials/SpriteEditor.vue @@ -4,46 +4,113 @@ <h3 class="m-0 font-medium shrink-0 text-white">Sprite editor</h3> </template> <template #modalBody> - <div class="m-4 flex gap-8"> - <div class="relative"> - <div - class="sprite-container bg-gray-800" - :style="{ - width: `${maxWidth}px`, - height: `${maxHeight}px`, - position: 'relative', - overflow: 'hidden' - }" - > + <div class="m-4 flex gap-4 h-full"> + <!-- Settings --> + <div class="w-80 h-full flex flex-col overflow-y-auto"> + <div class="flex flex-col gap-4"> + <div class="flex flex-col"> + <label class="block mb-1 text-white text-sm">Frame Rate: {{ frameRate }} FPS</label> + <div class="text-xs font-default text-gray-400 mb-1">Duration: {{ totalDuration }}s</div> + <input type="range" v-model.number="localFrameRate" min="0" max="60" step="1" class="w-full accent-cyan-500" @input="updateFrameRate" /> + </div> + <div class="flex flex-col"> + <label class="block mb-1 text-white text-sm">Frame: {{ currentFrame + 1 }} of {{ sprites.length }}</label> + <input type="range" v-model.number="currentFrame" :min="0" :max="sprites.length - 1" step="1" class="w-full accent-cyan-500" @input="stopAnimation" /> + </div> + <div class="flex flex-col"> + <label class="block mb-1 text-white text-sm">Zoom: {{ zoomLevel }}%</label> + <input type="range" v-model.number="zoomLevel" min="10" max="600" step="10" class="w-full accent-cyan-500" /> + </div> + </div> + <div class="mt-6 space-y-2"> + <button @click="toggleAnimation" class="px-3 py-1 bg-cyan-600 hover:bg-cyan-700 text-white rounded transition-colors w-full"> + {{ isAnimating ? 'Pause' : 'Play' }} + </button> + <button @click="toggleReferenceSprites" class="px-3 py-1 bg-cyan-600 hover:bg-cyan-700 text-white rounded transition-colors w-full"> + {{ showReferenceSprites ? 'Hide References' : 'Show References' }} + </button> + </div> + <div class="mt-6"> + <form class="flex gap-2.5 flex-wrap" @submit.prevent=""> + <div class="form-field-full"> + <label for="action">Action</label> + <input class="input-field" type="text" name="action" placeholder="Action" /> + </div> + <div class="form-field-half"> + <label for="origin-x">Origin X</label> + <input class="input-field" type="number" step="any" name="origin-x" placeholder="Origin X" /> + </div> + <div class="form-field-half"> + <label for="origin-y">Origin Y</label> + <input class="input-field" type="number" step="any" name="origin-y" placeholder="Origin Y" /> + </div> + <div class="form-field-half"> + <label for="offset-x">Offset X</label> + <input class="input-field" type="number" step="1" :value="currentSprite?.offset?.x || 0" @input="updateOffset($event, 'x')" :disabled="isAnimating" /> + </div> + <div class="form-field-half"> + <label for="offset-y">Offset Y</label> + <input class="input-field" type="number" step="1" :value="currentSprite?.offset?.y || 0" @input="updateOffset($event, 'y')" :disabled="isAnimating" /> + </div> + <div class="form-field-full"> + <label for="frame-speed">Frame rate</label> + <input class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame rate" /> + </div> + </form> + </div> + </div> + <!-- Sprite thumbnails --> + <div class="flex-1 flex flex-col h-full"> + <div class="bg-gray-800 border-solid border-white/10 rounded flex-grow mb-2 relative overflow-hidden" @mousedown="startDrag" @mousemove="onDrag" @mouseup="stopDrag" @mouseleave="stopDrag"> + <!-- Background reference sprites (semi-transparent) --> + <img + v-for="(sprite, index) in spritesWithTempOffset" + :key="`bg-${index}`" + :src="sprite.url" + alt="Reference sprite" + v-show="index !== currentFrame && showReferenceSprites" + :style="{ + position: 'absolute', + left: `${(sprite.offset?.x || 0) * (zoomLevel / 100)}px`, + bottom: `${(sprite.offset?.y || 0) * (zoomLevel / 100)}px`, + opacity: 0.3, + transform: `scale(${zoomLevel / 100})`, + transformOrigin: 'bottom left', + pointerEvents: 'none' + }" + /> + <!-- Current sprite (draggable) --> <img v-for="(sprite, index) in spritesWithTempOffset" :key="index" :src="sprite.url" alt="Sprite" + :class="{ 'cursor-move': currentFrame === index }" :style="{ position: 'absolute', - left: `${sprite.offset?.x || 0}px`, - bottom: `${sprite.offset?.y || 0}px`, + left: `${(sprite.offset?.x || 0) * (zoomLevel / 100)}px`, + bottom: `${(sprite.offset?.y || 0) * (zoomLevel / 100)}px`, display: currentFrame === index ? 'block' : 'none', transform: `scale(${zoomLevel / 100})`, transformOrigin: 'bottom left' }" - @load="updateContainerSize" /> </div> - </div> - <div class="flex flex-col justify-center gap-8 flex-1"> - <div class="flex flex-col"> - <label class="block mb-2 text-white">Frame Rate: {{ frameRate }} FPS (Duration: {{ totalDuration }}s)</label> - <input type="range" v-model.number="localFrameRate" min="0" max="60" step="1" class="w-full accent-cyan-500" @input="updateFrameRate" /> - </div> - <div class="flex flex-col"> - <label class="block mb-2 text-white">Frame: {{ currentFrame + 1 }} of {{ sprites.length }}</label> - <input type="range" v-model.number="currentFrame" :min="0" :max="sprites.length - 1" step="1" class="w-full accent-cyan-500" @input="stopAnimation" /> - </div> - <div class="flex flex-col"> - <label class="block mb-2 text-white">Zoom: {{ zoomLevel }}%</label> - <input type="range" v-model.number="zoomLevel" min="10" max="200" step="10" class="w-full accent-cyan-500" /> + <div class="bg-gray-800 p-2 overflow-x-auto border-solid border-white/10 rounded mb-8 h-24 min-h-16"> + <div class="flex gap-2"> + <div + v-for="(sprite, index) in sprites" + :key="`thumb-${index}`" + class="relative cursor-pointer border-solid transition-all duration-200 rounded flex-shrink-0 p-3 px-12" + :class="currentFrame === index ? 'border-cyan-600 bg-cyan-500/10' : 'border-transparent hover:border-white/30'" + @click="selectFrame(index)" + > + <img :src="sprite.url" alt="Sprite thumbnail" class="h-16 w-auto object-contain rounded" /> + <div class="absolute top-0 right-0 bg-gray-400 text-white text-xs font-default px-1 rounded-bl"> + {{ index + 1 }} + </div> + </div> + </div> </div> </div> </div> @@ -52,11 +119,12 @@ </template> <script setup lang="ts"> -import type { SpriteImage } from '@/application/types' +import type { Sprite, SpriteImage } from '@/application/types' import Modal from '@/components/utilities/Modal.vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' const props = defineProps<{ + sprite: Sprite sprites: SpriteImage[] frameRate: number isModalOpen?: boolean @@ -67,13 +135,16 @@ const props = defineProps<{ const emit = defineEmits<{ (e: 'update:frameRate', value: number): void (e: 'update:isModalOpen', value: boolean): void + (e: 'update:tempOffset', index: number, offset: { x: number; y: number }): void }>() const currentFrame = ref(0) -const maxWidth = ref(250) -const maxHeight = ref(250) const localFrameRate = ref(props.frameRate) const zoomLevel = ref(100) +const isAnimating = ref(false) +const isDragging = ref(false) +const startDragPos = ref({ x: 0, y: 0 }) +const currentOffset = ref({ x: 0, y: 0 }) let animationInterval: number | null = null const totalDuration = computed(() => { @@ -90,11 +161,15 @@ const spritesWithTempOffset = computed(() => { }) }) -function updateContainerSize(event: Event) { - const img = event.target as HTMLImageElement - maxWidth.value = Math.max(maxWidth.value, img.naturalWidth) - maxHeight.value = Math.max(maxHeight.value, img.naturalHeight) -} +const currentSprite = computed(() => { + if (currentFrame.value >= 0 && currentFrame.value < spritesWithTempOffset.value.length) { + return spritesWithTempOffset.value[currentFrame.value] + } + return null +}) + +// Toggle for showing reference sprites +const showReferenceSprites = ref(true) function updateAnimation() { stopAnimation() @@ -103,9 +178,20 @@ function updateAnimation() { return } - animationInterval = window.setInterval(() => { - currentFrame.value = (currentFrame.value + 1) % props.sprites.length - }, 1000 / props.frameRate) + if (isAnimating.value) { + animationInterval = window.setInterval(() => { + currentFrame.value = (currentFrame.value + 1) % props.sprites.length + }, 1000 / props.frameRate) + } +} + +function toggleAnimation() { + isAnimating.value = !isAnimating.value + if (isAnimating.value) { + updateAnimation() + } else { + stopAnimation() + } } function stopAnimation() { @@ -115,6 +201,12 @@ function stopAnimation() { } } +function selectFrame(index: number) { + currentFrame.value = index + stopAnimation() + isAnimating.value = false +} + function updateFrameRate() { emit('update:frameRate', localFrameRate.value) } @@ -123,6 +215,77 @@ function closeModal() { emit('update:isModalOpen', false) } +function startDrag(event: MouseEvent) { + if (isAnimating.value) return + + const previewContainer = event.currentTarget as HTMLElement + const rect = previewContainer.getBoundingClientRect() + + // Store initial mouse position + startDragPos.value = { + x: event.clientX, + y: event.clientY + } + + // Store current offset + if (currentSprite.value && currentSprite.value.offset) { + currentOffset.value = { + x: currentSprite.value.offset.x, + y: currentSprite.value.offset.y + } + } else { + currentOffset.value = { x: 0, y: 0 } + } + + isDragging.value = true +} + +function onDrag(event: MouseEvent) { + if (!isDragging.value) return + + // Calculate the difference from the start position + const deltaX = event.clientX - startDragPos.value.x + const deltaY = startDragPos.value.y - event.clientY // Inverted for bottom positioning + + // Apply the zoom factor to the delta + // This ensures that the movement in screen pixels is converted to the correct + // number of pixels at the sprite's natural size, regardless of zoom level + const zoomFactor = 100 / zoomLevel.value + const scaledDeltaX = deltaX * zoomFactor + const scaledDeltaY = deltaY * zoomFactor + + // Calculate new offset + // These offsets are in the sprite's natural coordinate space (as if zoom was 100%) + const newOffset = { + x: currentOffset.value.x + scaledDeltaX, + y: currentOffset.value.y + scaledDeltaY + } + + // Emit the new offset + emit('update:tempOffset', currentFrame.value, newOffset) +} + +function stopDrag() { + isDragging.value = false +} + +function toggleReferenceSprites() { + showReferenceSprites.value = !showReferenceSprites.value +} + +function updateOffset(event: Event, axis: 'x' | 'y') { + if (isAnimating.value) return + + const input = event.target as HTMLInputElement + const value = parseInt(input.value) || 0 + + if (currentSprite.value && currentSprite.value.offset) { + const newOffset = { ...currentSprite.value.offset } + newOffset[axis] = value + emit('update:tempOffset', currentFrame.value, newOffset) + } +} + watch( () => props.frameRate, (newValue) => { @@ -134,18 +297,25 @@ watch( watch(() => props.sprites, updateAnimation, { immediate: true }) +watch( + () => isAnimating.value, + (newValue) => { + if (newValue) { + updateAnimation() + } else { + stopAnimation() + } + } +) + onMounted(() => { - updateAnimation() + isAnimating.value = props.frameRate > 0 + if (isAnimating.value) { + updateAnimation() + } }) onUnmounted(() => { stopAnimation() }) </script> - -<style scoped> -.sprite-container { - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; -} -</style>