From f268ac9e5b803705a320b2d510891bf28a49a348 Mon Sep 17 00:00:00 2001 From: Dennis Postma <dennis@directonline.io> Date: Thu, 30 Jan 2025 18:29:55 +0100 Subject: [PATCH] Removed comment, updated types for sprite actions, minor modal component improvement, added components for better sprite management --- src/application/types.ts | 10 +- .../partials/sprite/SpriteDetails.vue | 49 +++++++++- .../sprite/partials/SpriteImagesInput.vue | 91 ++++++++++++++++--- .../sprite/partials/SpritePreview.vue | 85 +++++++++++++++++ .../mapEditor/partials/CreateMap.vue | 4 +- src/components/utilities/Modal.vue | 8 +- src/composables/gameComposable.ts | 1 - 7 files changed, 225 insertions(+), 23 deletions(-) create mode 100644 src/components/gameMaster/assetManager/partials/sprite/partials/SpritePreview.vue diff --git a/src/application/types.ts b/src/application/types.ts index efd7c26..218ee95 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -216,11 +216,19 @@ export type Sprite = { characterTypes: CharacterType[] } +export interface SpriteImage { + url: string + offset: { + x: number + y: number + } +} + export type SpriteAction = { id: UUID sprite: Sprite action: string - sprites: string[] + sprites: SpriteImage[] originX: number originY: number frameWidth: number diff --git a/src/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue b/src/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue index 9d76450..c4c6c3f 100644 --- a/src/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue +++ b/src/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue @@ -21,9 +21,12 @@ <button class="btn-cyan py-2 my-4" type="button" @click.prevent="addNewImage">New action</button> <Accordion v-for="action in spriteActions" :key="action.id"> <template #header> - <div class="flex justify-between items-center"> + <div class="flex items-center"> {{ action.action }} - <button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.prevent="() => spriteActions.splice(spriteActions.indexOf(action), 1)">Delete</button> + <div class="ml-auto space-x-2"> + <button class="btn-cyan px-4 py-1.5 min-w-24" type="button" @click.prevent="openPreviewModal(action)">View</button> + <button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.prevent="() => spriteActions.splice(spriteActions.indexOf(action), 1)">Delete</button> + </div> </div> </template> <template #content> @@ -50,6 +53,22 @@ </form> </template> </Accordion> + <Modal :is-modal-open="isModalOpen" :modal-width="300" :modal-height="420" :bg-style="'none'" @modal:close="isModalOpen = false"> + <template #modalHeader> + <h3 class="m-0 font-medium shrink-0 text-white">View sprite</h3> + </template> + <template #modalBody> + <div class="m-4 flex flex-col gap-4"> + <div v-if="selectedAction" class="flex flex-col items-center"> + <SpritePreview :sprites="selectedAction.sprites" :frame-rate="previewFps" /> + <div class="w-full mt-4"> + <label class="block mb-2 text-white">Frame Rate: {{ previewFps }} FPS</label> + <input type="range" v-model.number="previewFps" min="0" max="60" step="1" class="w-full accent-cyan-500" @input="updatePreviewFps" /> + </div> + </div> + </div> + </template> + </Modal> </div> </div> </template> @@ -58,7 +77,9 @@ import type { Sprite, SpriteAction } from '@/application/types' import { uuidv4 } from '@/application/utilities' import SpriteActionsInput from '@/components/gameMaster/assetManager/partials/sprite/partials/SpriteImagesInput.vue' +import SpritePreview from '@/components/gameMaster/assetManager/partials/sprite/partials/SpritePreview.vue' import Accordion from '@/components/utilities/Accordion.vue' +import Modal from '@/components/utilities/Modal.vue' import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useGameStore } from '@/stores/gameStore' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' @@ -176,6 +197,30 @@ watch(selectedSprite, (sprite: Sprite | null) => { spriteActions.value = sortSpriteActions(sprite.spriteActions) }) +// View sprite modal logic +const isModalOpen = ref(false) +const selectedAction = ref<SpriteAction | null>(null) +const previewFps = ref(0) + +function openPreviewModal(action: SpriteAction) { + selectedAction.value = action + previewFps.value = action.frameRate || 0 + isModalOpen.value = true +} + +watch(isModalOpen, (newValue) => { + if (!newValue) { + selectedAction.value = null + previewFps.value = 0 + } +}) + +function updatePreviewFps() { + if (selectedAction.value) { + selectedAction.value.frameRate = previewFps.value + } +} + onMounted(() => { if (!selectedSprite.value) return }) diff --git a/src/components/gameMaster/assetManager/partials/sprite/partials/SpriteImagesInput.vue b/src/components/gameMaster/assetManager/partials/sprite/partials/SpriteImagesInput.vue index 000ed1c..3d06e75 100644 --- a/src/components/gameMaster/assetManager/partials/sprite/partials/SpriteImagesInput.vue +++ b/src/components/gameMaster/assetManager/partials/sprite/partials/SpriteImagesInput.vue @@ -1,19 +1,43 @@ <template> <div class="flex flex-wrap gap-3"> <div v-for="(image, index) in modelValue" :key="index" class="h-20 w-20 p-4 bg-gray-300 bg-opacity-50 rounded text-center relative group cursor-move" draggable="true" @dragstart="dragStart($event, index)" @dragover.prevent @dragenter.prevent @drop="drop($event, index)"> - <img :src="image" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" /> + <img :src="image.url" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" /> <div class="absolute top-1 left-1 flex-row space-y-1"> <button @click.stop="deleteImage(index)" class="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity" aria-label="Delete image"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> </svg> </button> - <button class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity" aria-label="Scope image"> - <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + <button @click.stop="openOffsetModal(index)" class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity" aria-label="Scope image"> + <svg width="50px" height="50px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M8.29289 3.70711L1 11V15H5L12.2929 7.70711L8.29289 3.70711Z" fill="white" /> + <path d="M9.70711 2.29289L13.7071 6.29289L15.1716 4.82843C15.702 4.29799 16 3.57857 16 2.82843C16 1.26633 14.7337 0 13.1716 0C12.4214 0 11.702 0.297995 11.1716 0.828428L9.70711 2.29289Z" fill="white" /> </svg> </button> </div> + + <Modal :is-modal-open="selectedImageIndex === index" :modal-width="300" :modal-height="210" :is-resizable="false" :bg-style="'none'" @modal:close="closeOffsetModal"> + <template #modalHeader> + <h3 class="m-0 font-medium shrink-0 text-white">Action offset</h3> + </template> + <template #modalBody> + <div class="m-4"> + <form method="post" @submit.prevent="saveOffset(index)" class="inline"> + <div class="gap-2.5 flex flex-wrap"> + <div class="form-field-half"> + <label for="offsetX">Offset X</label> + <input class="input-field max-w-64" v-model="tempOffset.x" name="offsetX" id="offsetX" type="number" /> + </div> + <div class="form-field-half"> + <label for="offsetY">Offset Y</label> + <input class="input-field max-w-64" v-model="tempOffset.y" name="offsetY" id="offsetY" type="number" /> + </div> + </div> + <button class="btn-cyan px-4 py-1.5 min-w-24" type="submit">Save</button> + </form> + </div> + </template> + </Modal> </div> <div class="h-20 w-20 p-4 bg-gray-200 bg-opacity-50 rounded justify-center items-center flex hover:cursor-pointer" @click="triggerFileInput" @drop.prevent="onDrop" @dragover.prevent> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 invert" fill="none" viewBox="0 0 24 24" stroke="currentColor"> @@ -25,10 +49,19 @@ </template> <script setup lang="ts"> +import Modal from '@/components/utilities/Modal.vue' import { ref } from 'vue' +interface SpriteImage { + url: string + offset: { + x: number + y: number + } +} + interface Props { - modelValue: string[] + modelValue: SpriteImage[] } const props = withDefaults(defineProps<Props>(), { @@ -36,11 +69,14 @@ const props = withDefaults(defineProps<Props>(), { }) const emit = defineEmits<{ - (e: 'update:modelValue', value: string[]): void + (e: 'update:modelValue', value: SpriteImage[]): void + (e: 'close'): void }>() const fileInput = ref<HTMLInputElement | null>(null) const draggedIndex = ref<number | null>(null) +const selectedImageIndex = ref<number | null>(null) +const tempOffset = ref({ x: 0, y: 0 }) const triggerFileInput = () => { fileInput.value?.click() @@ -61,19 +97,25 @@ const onDrop = (event: DragEvent) => { const handleFiles = (files: FileList) => { Array.from(files).forEach((file) => { - if (file.type.startsWith('image/')) { - const reader = new FileReader() - reader.onload = (e) => { - if (typeof e.target?.result === 'string') { - updateImages([...props.modelValue, e.target.result]) - } - } - reader.readAsDataURL(file) + if (!file.type.startsWith('image/')) { + return } + + const reader = new FileReader() + reader.onload = (e) => { + if (typeof e.target?.result === 'string') { + const newImage: SpriteImage = { + url: e.target.result, + offset: { x: 0, y: 0 } + } + updateImages([...props.modelValue, newImage]) + } + } + reader.readAsDataURL(file) }) } -const updateImages = (newImages: string[]) => { +const updateImages = (newImages: SpriteImage[]) => { emit('update:modelValue', newImages) } @@ -101,4 +143,23 @@ const drop = (event: DragEvent, dropIndex: number) => { } draggedIndex.value = null } + +const openOffsetModal = (index: number) => { + selectedImageIndex.value = index + tempOffset.value = { ...props.modelValue[index].offset } +} + +const closeOffsetModal = () => { + selectedImageIndex.value = null +} + +const saveOffset = (index: number) => { + const newImages = [...props.modelValue] + newImages[index] = { + ...newImages[index], + offset: { ...tempOffset.value } + } + updateImages(newImages) + closeOffsetModal() +} </script> diff --git a/src/components/gameMaster/assetManager/partials/sprite/partials/SpritePreview.vue b/src/components/gameMaster/assetManager/partials/sprite/partials/SpritePreview.vue new file mode 100644 index 0000000..a4c1731 --- /dev/null +++ b/src/components/gameMaster/assetManager/partials/sprite/partials/SpritePreview.vue @@ -0,0 +1,85 @@ +<template> + <div class="relative"> + <div + class="sprite-container bg-gray-800" + :style="{ + width: `${maxWidth}px`, + height: `${maxHeight}px`, + position: 'relative', + overflow: 'hidden' + }" + > + <img + v-for="(sprite, index) in sprites" + :key="index" + :src="sprite.url" + alt="Sprite" + :style="{ + position: 'absolute', + left: `${sprite.offset?.x || 0}px`, + bottom: `${sprite.offset?.y || 0}px`, + display: currentFrame === index ? 'block' : 'none' + }" + @load="updateContainerSize" + /> + </div> + </div> +</template> + +<script setup lang="ts"> +import type { SpriteImage } from '@/application/types' +import { onMounted, onUnmounted, ref, watch } from 'vue' + +const props = defineProps<{ + sprites: SpriteImage[] + frameRate: number +}>() + +const currentFrame = ref(0) +const maxWidth = ref(250) +const maxHeight = ref(250) +let animationInterval: number | null = null + +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) +} + +function updateAnimation() { + stopAnimation() + if (props.frameRate <= 0 || props.sprites.length === 0) { + currentFrame.value = 0 + return + } + + animationInterval = window.setInterval(() => { + currentFrame.value = (currentFrame.value + 1) % props.sprites.length + }, 1000 / props.frameRate) +} + +function stopAnimation() { + if (animationInterval) { + clearInterval(animationInterval) + animationInterval = null + } +} + +watch(() => props.frameRate, updateAnimation, { immediate: true }) +watch(() => props.sprites, updateAnimation, { immediate: true }) + +onMounted(() => { + updateAnimation() +}) + +onUnmounted(() => { + stopAnimation() +}) +</script> + +<style scoped> +.sprite-container { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; +} +</style> diff --git a/src/components/gameMaster/mapEditor/partials/CreateMap.vue b/src/components/gameMaster/mapEditor/partials/CreateMap.vue index 50926e4..8314248 100644 --- a/src/components/gameMaster/mapEditor/partials/CreateMap.vue +++ b/src/components/gameMaster/mapEditor/partials/CreateMap.vue @@ -13,11 +13,11 @@ </div> <div class="form-field-half"> <label for="name">Width</label> - <input class="input-field max-w-64" v-model="width" name="name" id="name" type="number" /> + <input class="input-field max-w-64" v-model="width" name="width" id="width" type="number" /> </div> <div class="form-field-half"> <label for="name">Height</label> - <input class="input-field max-w-64" v-model="height" name="name" id="name" type="number" /> + <input class="input-field max-w-64" v-model="height" name="height" id="height" type="number" /> </div> <div class="form-field-full"> <label for="name">PVP enabled</label> diff --git a/src/components/utilities/Modal.vue b/src/components/utilities/Modal.vue index fc9bc7e..ec4ff29 100644 --- a/src/components/utilities/Modal.vue +++ b/src/components/utilities/Modal.vue @@ -17,7 +17,7 @@ <button v-if="canFullScreen" @click="toggleFullScreen" class="w-5 h-5 m-0 p-0 relative hover:scale-110 transition-transform duration-300 ease-in-out"> <img :alt="isFullScreen ? 'exit full-screen' : 'full-screen'" :src="isFullScreen ? '/assets/icons/modal/minimize.svg' : '/assets/icons/modal/increase-size-option.svg'" class="w-3.5 h-3.5 invert" draggable="false" /> </button> - <button v-if="closable" @click="isModalOpenRef = false" class="w-3.5 h-3.5 m-0 p-0 relative hover:rotate-180 transition-transform duration-300 ease-in-out"> + <button v-if="closable" @click="closeModal" class="w-3.5 h-3.5 m-0 p-0 relative hover:rotate-180 transition-transform duration-300 ease-in-out"> <img alt="close" src="/assets/icons/modal/close-button-white.svg" class="w-full h-full" draggable="false" /> </button> </div> @@ -81,7 +81,6 @@ const props = withDefaults(defineProps<ModalProps>(), { const emit = defineEmits<{ 'modal:open': [] 'modal:close': [] - 'character:create': [] }>() defineExpose({ @@ -157,6 +156,11 @@ function drag(event: MouseEvent) { y.value = dragState.initialY + (event.clientY - dragState.startY) } +function closeModal() { + isModalOpenRef.value = false + emit('modal:close') +} + function toggleFullScreen() { if (isFullScreen.value) { Object.assign({ x, y, width, height }, preFullScreenState) diff --git a/src/composables/gameComposable.ts b/src/composables/gameComposable.ts index 9a18791..4ab7d16 100644 --- a/src/composables/gameComposable.ts +++ b/src/composables/gameComposable.ts @@ -59,7 +59,6 @@ export async function loadTexture(scene: Phaser.Scene, textureData: TextureData) export async function loadSpriteTextures(scene: Phaser.Scene, sprite_id: string) { if (!sprite_id) return false - // @TODO: Fix this const spriteStorage = new SpriteStorage() const sprite = await spriteStorage.get(sprite_id)