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>