forked from noxious/client
Improvements
This commit is contained in:
58
src/components/gameMaster/mapEditor/partials/CreateMap.vue
Normal file
58
src/components/gameMaster/mapEditor/partials/CreateMap.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Modal :isModalOpen="true" @modal:close="() => mapEditorStore.toggleCreateMapModal()" :modal-width="300" :modal-height="420" :is-resizable="false" :bg-style="'none'">
|
||||
<template #modalHeader>
|
||||
<h3 class="m-0 font-medium shrink-0 text-white">Create new map</h3>
|
||||
</template>
|
||||
|
||||
<template #modalBody>
|
||||
<div class="m-4">
|
||||
<form method="post" @submit.prevent="submit" class="inline">
|
||||
<div class="gap-2.5 flex flex-wrap">
|
||||
<div class="form-field-full">
|
||||
<label for="name">Name</label>
|
||||
<input class="input-field max-w-64" v-model="name" name="name" id="name" />
|
||||
</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" />
|
||||
</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" />
|
||||
</div>
|
||||
<div class="form-field-full">
|
||||
<label for="name">PVP enabled</label>
|
||||
<select class="input-field" name="pvp" id="pvp">
|
||||
<option :value="false">No</option>
|
||||
<option :value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-cyan px-4 py-1.5 min-w-24" type="submit">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map } from '@/application/types'
|
||||
import Modal from '@/components/utilities/Modal.vue'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useMapEditorStore } from '@/stores/mapEditorStore'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const mapEditorStore = useMapEditorStore()
|
||||
|
||||
const name = ref('')
|
||||
const width = ref(0)
|
||||
const height = ref(0)
|
||||
|
||||
function submit() {
|
||||
gameStore.connection?.emit('gm:map:create', { name: name.value, width: width.value, height: height.value }, (response: Map[]) => {
|
||||
mapEditorStore.setMapList(response)
|
||||
})
|
||||
mapEditorStore.toggleCreateMapModal()
|
||||
}
|
||||
</script>
|
61
src/components/gameMaster/mapEditor/partials/MapList.vue
Normal file
61
src/components/gameMaster/mapEditor/partials/MapList.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<CreateMap v-if="mapEditorStore.isCreateMapModalShown" />
|
||||
<Modal :is-modal-open="mapEditorStore.isMapListModalShown" @modal:close="() => mapEditorStore.toggleMapListModal()" :is-resizable="false" :modal-width="300" :modal-height="360" :bg-style="'none'">
|
||||
<template #modalHeader>
|
||||
<h3 class="text-lg text-white">Maps</h3>
|
||||
</template>
|
||||
<template #modalBody>
|
||||
<div class="my-4 mx-auto">
|
||||
<div class="text-center mb-4 px-2 flex gap-2.5">
|
||||
<button class="btn-cyan py-1.5 min-w-[calc(50%_-_5px)]" @click="fetchMaps">Refresh</button>
|
||||
<button class="btn-cyan py-1.5 min-w-[calc(50%_-_5px)]" @click="() => mapEditorStore.toggleCreateMapModal()">New</button>
|
||||
</div>
|
||||
<div class="relative p-2.5 cursor-pointer flex gap-y-2.5 gap-x-5 flex-wrap" v-for="(map, index) in mapEditorStore.mapList" :key="map.id">
|
||||
<div class="absolute left-0 top-0 w-full h-px bg-gray-500" v-if="index === 0"></div>
|
||||
<div class="flex gap-3 items-center w-full" @click="() => loadMap(map.id)">
|
||||
<span>{{ map.name }}</span>
|
||||
<span class="ml-auto gap-1 flex">
|
||||
<button class="btn-red w-7 h-7 z-50 flex items-center justify-center" @click.stop="() => deleteMap(map.id)">x</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map, UUID } from '@/application/types'
|
||||
import CreateMap from '@/components/gameMaster/mapEditor/partials/CreateMap.vue'
|
||||
import Modal from '@/components/utilities/Modal.vue'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useMapEditorStore } from '@/stores/mapEditorStore'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const mapEditorStore = useMapEditorStore()
|
||||
|
||||
onMounted(async () => {
|
||||
fetchMaps()
|
||||
})
|
||||
|
||||
function fetchMaps() {
|
||||
gameStore.connection?.emit('gm:map:list', {}, (response: Map[]) => {
|
||||
mapEditorStore.setMapList(response)
|
||||
})
|
||||
}
|
||||
|
||||
function loadMap(id: UUID) {
|
||||
gameStore.connection?.emit('gm:map:request', { mapId: id }, (response: Map) => {
|
||||
mapEditorStore.setMap(response)
|
||||
})
|
||||
mapEditorStore.toggleMapListModal()
|
||||
}
|
||||
|
||||
function deleteMap(id: UUID) {
|
||||
gameStore.connection?.emit('gm:map:delete', { mapId: id }, () => {
|
||||
fetchMaps()
|
||||
})
|
||||
}
|
||||
</script>
|
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<Modal :isModalOpen="mapEditorStore.isMapObjectListModalShown" :modal-width="645" :modal-height="260" @modal:close="() => (mapEditorStore.isMapObjectListModalShown = false)" :bg-style="'none'">
|
||||
<template #modalHeader>
|
||||
<h3 class="text-lg text-white">Map objects</h3>
|
||||
</template>
|
||||
<template #modalBody>
|
||||
<div class="flex pt-4 pl-4">
|
||||
<div class="w-full flex gap-1.5 flex-row">
|
||||
<div>
|
||||
<label class="mb-1.5 font-titles hidden" for="search">Search...</label>
|
||||
<input @mousedown.stop class="input-field" type="text" name="search" placeholder="Search" v-model="searchQuery" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col h-full p-4">
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<button v-for="tag in uniqueTags" :key="tag" @click="toggleTag(tag)" class="btn-cyan" :class="{ 'opacity-50': !selectedTags.includes(tag) }">
|
||||
{{ tag }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-full overflow-auto">
|
||||
<div class="flex justify-between flex-wrap gap-2.5 items-center">
|
||||
<div v-for="(mapObject, index) in filteredMapObjects" :key="index" class="max-w-1/4 inline-block">
|
||||
<img
|
||||
class="border-2 border-solid max-w-full"
|
||||
:src="`${config.server_endpoint}/textures/map_objects/${mapObject.id}.png`"
|
||||
alt="Object"
|
||||
@click="mapEditorStore.setSelectedMapObject(mapObject)"
|
||||
:class="{
|
||||
'cursor-pointer transition-all duration-300': true,
|
||||
'border-cyan shadow-lg scale-105': mapEditorStore.selectedMapObject?.id === mapObject.id,
|
||||
'border-transparent hover:border-gray-300': mapEditorStore.selectedMapObject?.id !== mapObject.id
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import config from '@/application/config'
|
||||
import type { MapObject } from '@/application/types'
|
||||
import Modal from '@/components/utilities/Modal.vue'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useMapEditorStore } from '@/stores/mapEditorStore'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const isModalOpen = ref(false)
|
||||
const mapEditorStore = useMapEditorStore()
|
||||
const searchQuery = ref('')
|
||||
const selectedTags = ref<string[]>([])
|
||||
|
||||
const uniqueTags = computed(() => {
|
||||
const allTags = mapEditorStore.mapObjectList.flatMap((obj) => obj.tags || [])
|
||||
return Array.from(new Set(allTags))
|
||||
})
|
||||
|
||||
const filteredMapObjects = computed(() => {
|
||||
return mapEditorStore.mapObjectList.filter((object) => {
|
||||
const matchesSearch = !searchQuery.value || object.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchesTags = selectedTags.value.length === 0 || (object.tags && selectedTags.value.some((tag) => object.tags.includes(tag)))
|
||||
return matchesSearch && matchesTags
|
||||
})
|
||||
})
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
if (selectedTags.value.includes(tag)) {
|
||||
selectedTags.value = selectedTags.value.filter((t) => t !== tag)
|
||||
} else {
|
||||
selectedTags.value.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isModalOpen.value = true
|
||||
gameStore.connection?.emit('gm:mapObject:list', {}, (response: MapObject[]) => {
|
||||
mapEditorStore.setMapObjectList(response)
|
||||
})
|
||||
})
|
||||
</script>
|
106
src/components/gameMaster/mapEditor/partials/MapSettings.vue
Normal file
106
src/components/gameMaster/mapEditor/partials/MapSettings.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<Modal :is-modal-open="mapEditorStore.isSettingsModalShown" @modal:close="() => mapEditorStore.toggleSettingsModal()" :modal-width="600" :modal-height="430" :bg-style="'none'">
|
||||
<template #modalHeader>
|
||||
<h3 class="m-0 font-medium shrink-0 text-white">Map settings</h3>
|
||||
</template>
|
||||
|
||||
<template #modalBody>
|
||||
<div class="m-4">
|
||||
<div class="space-x-2">
|
||||
<button class="btn-cyan py-1.5 px-4" type="button" @click.prevent="screen = 'settings'">Settings</button>
|
||||
<button class="btn-cyan py-1.5 px-4" type="button" @click.prevent="screen = 'effects'">Effects</button>
|
||||
</div>
|
||||
<form method="post" @submit.prevent="" class="inline" v-if="screen === 'settings'">
|
||||
<div class="gap-2.5 flex flex-wrap mt-4">
|
||||
<div class="form-field-full">
|
||||
<label for="name">Name</label>
|
||||
<input class="input-field" v-model="name" name="name" id="name" />
|
||||
</div>
|
||||
<div class="form-field-half">
|
||||
<label for="width">Width</label>
|
||||
<input class="input-field" v-model="width" name="width" id="width" type="number" />
|
||||
</div>
|
||||
<div class="form-field-half">
|
||||
<label for="height">Height</label>
|
||||
<input class="input-field" v-model="height" name="height" id="height" type="number" />
|
||||
</div>
|
||||
<div class="form-field-full">
|
||||
<label for="pvp">PVP enabled</label>
|
||||
<select v-model="pvp" class="input-field" name="pvp" id="pvp">
|
||||
<option :value="false">No</option>
|
||||
<option :value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" @submit.prevent="" class="inline" v-if="screen === 'effects'">
|
||||
<div v-for="(effect, index) in mapEffects" :key="effect.id" class="mb-2 flex items-center space-x-2 mt-4">
|
||||
<input class="input-field flex-grow" v-model="effect.effect" placeholder="Effect name" />
|
||||
<input class="input-field w-20" v-model.number="effect.strength" type="number" placeholder="Strength" />
|
||||
<button class="btn-red py-1 px-2" type="button" @click="removeEffect(index)">Delete</button>
|
||||
</div>
|
||||
<button class="btn-green py-1 px-2 mt-2" type="button" @click="addEffect">Add Effect</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Modal from '@/components/utilities/Modal.vue'
|
||||
import { useMapEditorStore } from '@/stores/mapEditorStore'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const mapEditorStore = useMapEditorStore()
|
||||
const screen = ref('settings')
|
||||
|
||||
mapEditorStore.setMapName(mapEditorStore.map?.name)
|
||||
mapEditorStore.setMapWidth(mapEditorStore.map?.width)
|
||||
mapEditorStore.setMapHeight(mapEditorStore.map?.height)
|
||||
mapEditorStore.setMapPvp(mapEditorStore.map?.pvp)
|
||||
mapEditorStore.setMapEffects(mapEditorStore.map?.mapEffects)
|
||||
|
||||
const name = ref(mapEditorStore.mapSettings?.name)
|
||||
const width = ref(mapEditorStore.mapSettings?.width)
|
||||
const height = ref(mapEditorStore.mapSettings?.height)
|
||||
const pvp = ref(mapEditorStore.mapSettings?.pvp)
|
||||
const mapEffects = ref(mapEditorStore.mapSettings?.mapEffects || [])
|
||||
|
||||
watch(name, (value) => {
|
||||
mapEditorStore.setMapName(value)
|
||||
})
|
||||
|
||||
watch(width, (value) => {
|
||||
mapEditorStore.setMapWidth(value)
|
||||
})
|
||||
|
||||
watch(height, (value) => {
|
||||
mapEditorStore.setMapHeight(value)
|
||||
})
|
||||
|
||||
watch(pvp, (value) => {
|
||||
mapEditorStore.setMapPvp(value)
|
||||
})
|
||||
|
||||
watch(
|
||||
mapEffects,
|
||||
(value) => {
|
||||
mapEditorStore.setMapEffects(value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const addEffect = () => {
|
||||
mapEffects.value.push({
|
||||
id: Date.now().toString(), // Simple unique id generation
|
||||
mapId: mapEditorStore.map?.id,
|
||||
map: mapEditorStore.map,
|
||||
effect: '',
|
||||
strength: 1
|
||||
})
|
||||
}
|
||||
|
||||
const removeEffect = (index) => {
|
||||
mapEffects.value.splice(index, 1)
|
||||
}
|
||||
</script>
|
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center py-5 px-3 fixed bottom-14 right-0">
|
||||
<div class="self-end mt-2 flex gap-2">
|
||||
<button @mousedown.stop @click="handleDelete" class="btn-red py-1.5 px-4">
|
||||
<img src="/assets/icons/trashcan.svg" class="w-4 h-4" alt="Delete" />
|
||||
</button>
|
||||
<button @mousedown.stop @click="handleRotate" class="btn-cyan py-1.5 px-4">Rotate</button>
|
||||
<button @mousedown.stop @click="handleMove" class="btn-cyan py-1.5 px-4 min-w-24">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PlacedMapObject } from '@/application/types'
|
||||
|
||||
const props = defineProps<{
|
||||
placedMapObject: PlacedMapObject
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['move', 'rotate', 'delete'])
|
||||
|
||||
const handleMove = () => {
|
||||
emit('move', props.placedMapObject.id)
|
||||
}
|
||||
|
||||
const handleRotate = () => {
|
||||
emit('rotate', props.placedMapObject.id)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.placedMapObject.id)
|
||||
}
|
||||
</script>
|
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<Modal :is-modal-open="showTeleportModal" @modal:close="() => mapEditorStore.setTool('move')" :modal-width="300" :modal-height="350" :is-resizable="false" :bg-style="'none'">
|
||||
<template #modalHeader>
|
||||
<h3 class="m-0 font-medium shrink-0 text-white">Teleport settings</h3>
|
||||
</template>
|
||||
<template #modalBody>
|
||||
<div class="m-4">
|
||||
<form method="post" @submit.prevent="" class="inline">
|
||||
<div class="gap-2.5 flex flex-wrap">
|
||||
<div class="form-field-half">
|
||||
<label for="positionX">Position X</label>
|
||||
<input class="input-field" v-model="toPositionX" name="positionX" id="positionX" type="number" />
|
||||
</div>
|
||||
<div class="form-field-half">
|
||||
<label for="positionY">Position Y</label>
|
||||
<input class="input-field" v-model="toPositionY" name="positionY" id="positionY" type="number" />
|
||||
</div>
|
||||
<div class="form-field-full">
|
||||
<label for="rotation">Rotation</label>
|
||||
<select v-model="toRotation" class="input-field" name="rotation" id="rotation">
|
||||
<option :value="0">North</option>
|
||||
<option :value="2">East</option>
|
||||
<option :value="4">South</option>
|
||||
<option :value="6">West</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field-full">
|
||||
<label for="toMap">Map to teleport to</label>
|
||||
<select v-model="toMap" class="input-field" name="toMap" id="toMap">
|
||||
<option :value="null">Select map</option>
|
||||
<option v-for="map in mapEditorStore.mapList" :key="map.id" :value="map">{{ map.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map } from '@/application/types'
|
||||
import Modal from '@/components/utilities/Modal.vue'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useMapEditorStore } from '@/stores/mapEditorStore'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const showTeleportModal = computed(() => mapEditorStore.tool === 'pencil' && mapEditorStore.drawMode === 'teleport')
|
||||
const mapEditorStore = useMapEditorStore()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
onMounted(fetchMaps)
|
||||
|
||||
function fetchMaps() {
|
||||
gameStore.connection?.emit('gm:map:list', {}, (response: Map[]) => {
|
||||
mapEditorStore.setMapList(response)
|
||||
})
|
||||
}
|
||||
|
||||
const { toPositionX, toPositionY, toRotation, toMap } = useRefTeleportSettings()
|
||||
|
||||
function useRefTeleportSettings() {
|
||||
const settings = mapEditorStore.teleportSettings
|
||||
return {
|
||||
toPositionX: ref(settings.toPositionX),
|
||||
toPositionY: ref(settings.toPositionY),
|
||||
toRotation: ref(settings.toRotation),
|
||||
toMap: ref(settings.toMap)
|
||||
}
|
||||
}
|
||||
|
||||
watch([toPositionX, toPositionY, toRotation, toMap], updateTeleportSettings)
|
||||
|
||||
function updateTeleportSettings() {
|
||||
mapEditorStore.setTeleportSettings({
|
||||
toPositionX: toPositionX.value,
|
||||
toPositionY: toPositionY.value,
|
||||
toRotation: toRotation.value,
|
||||
toMap: toMap.value
|
||||
})
|
||||
}
|
||||
</script>
|
236
src/components/gameMaster/mapEditor/partials/TileList.vue
Normal file
236
src/components/gameMaster/mapEditor/partials/TileList.vue
Normal file
@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<Modal :isModalOpen="mapEditorStore.isTileListModalShown" :modal-width="645" :modal-height="600" @modal:close="() => (mapEditorStore.isTileListModalShown = false)" :bg-style="'none'">
|
||||
<template #modalHeader>
|
||||
<h3 class="text-lg text-white">Tiles</h3>
|
||||
</template>
|
||||
<template #modalBody>
|
||||
<div class="h-full overflow-auto" v-if="!selectedGroup">
|
||||
<div class="flex pt-4 pl-4">
|
||||
<div class="w-full flex gap-1.5 flex-row">
|
||||
<div>
|
||||
<label class="mb-1.5 font-titles hidden" for="search">Search...</label>
|
||||
<input @mousedown.stop class="input-field" type="text" name="search" placeholder="Search" v-model="searchQuery" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col h-full p-4">
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<button v-for="tag in uniqueTags" :key="tag" @click="toggleTag(tag)" class="btn-cyan" :class="{ 'opacity-50': !selectedTags.includes(tag) }">
|
||||
{{ tag }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-[calc(100%_-_60px)] flex-grow overflow-y-auto">
|
||||
<div class="grid grid-cols-8 gap-2 justify-items-center">
|
||||
<div v-for="group in groupedTiles" :key="group.parent.id" class="flex flex-col items-center justify-center relative">
|
||||
<img
|
||||
class="max-w-full max-h-full border-2 border-solid cursor-pointer transition-all duration-300"
|
||||
:src="`${config.server_endpoint}/textures/tiles/${group.parent.id}.png`"
|
||||
:alt="group.parent.name"
|
||||
@click="openGroup(group)"
|
||||
@load="() => processTile(group.parent)"
|
||||
:class="{
|
||||
'border-cyan shadow-lg scale-105': isActiveTile(group.parent),
|
||||
'border-transparent hover:border-gray-300': !isActiveTile(group.parent)
|
||||
}"
|
||||
/>
|
||||
<span class="text-xs mt-1">{{ getTileCategory(group.parent) }}</span>
|
||||
<span v-if="group.children.length > 0" class="absolute top-0 right-0 bg-cyan text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">
|
||||
{{ group.children.length + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-full overflow-auto">
|
||||
<div class="p-4">
|
||||
<button @click="closeGroup" class="btn-cyan mb-4">Back to All Tiles</button>
|
||||
<h4 class="text-lg mb-4">{{ selectedGroup.parent.name }} Group</h4>
|
||||
<div class="grid grid-cols-8 gap-2 justify-items-center">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<img
|
||||
class="max-w-full max-h-full border-2 border-solid cursor-pointer transition-all duration-300"
|
||||
:src="`${config.server_endpoint}/textures/tiles/${selectedGroup.parent.id}.png`"
|
||||
:alt="selectedGroup.parent.name"
|
||||
@click="selectTile(selectedGroup.parent.id)"
|
||||
:class="{
|
||||
'border-cyan shadow-lg scale-105': isActiveTile(selectedGroup.parent),
|
||||
'border-transparent hover:border-gray-300': !isActiveTile(selectedGroup.parent)
|
||||
}"
|
||||
/>
|
||||
<span class="text-xs mt-1">{{ getTileCategory(selectedGroup.parent) }}</span>
|
||||
</div>
|
||||
<div v-for="childTile in selectedGroup.children" :key="childTile.id" class="flex flex-col items-center justify-center">
|
||||
<img
|
||||
class="max-w-full max-h-full border-2 border-solid cursor-pointer transition-all duration-300"
|
||||
:src="`${config.server_endpoint}/textures/tiles/${childTile.id}.png`"
|
||||
:alt="childTile.name"
|
||||
@click="selectTile(childTile.id)"
|
||||
:class="{
|
||||
'border-cyan shadow-lg scale-105': isActiveTile(childTile),
|
||||
'border-transparent hover:border-gray-300': !isActiveTile(childTile)
|
||||
}"
|
||||
/>
|
||||
<span class="text-xs mt-1">{{ getTileCategory(childTile) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import config from '@/application/config'
|
||||
import type { Tile } from '@/application/types'
|
||||
import Modal from '@/components/utilities/Modal.vue'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useMapEditorStore } from '@/stores/mapEditorStore'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const isModalOpen = ref(false)
|
||||
const mapEditorStore = useMapEditorStore()
|
||||
const searchQuery = ref('')
|
||||
const selectedTags = ref<string[]>([])
|
||||
const tileCategories = ref<Map<string, string>>(new Map())
|
||||
const selectedGroup = ref<{ parent: Tile; children: Tile[] } | null>(null)
|
||||
|
||||
const uniqueTags = computed(() => {
|
||||
const allTags = mapEditorStore.tileList.flatMap((tile) => tile.tags || [])
|
||||
return Array.from(new Set(allTags))
|
||||
})
|
||||
|
||||
const groupedTiles = computed(() => {
|
||||
const groups: { parent: Tile; children: Tile[] }[] = []
|
||||
const filteredTiles = mapEditorStore.tileList.filter((tile) => {
|
||||
const matchesSearch = !searchQuery.value || tile.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchesTags = selectedTags.value.length === 0 || (tile.tags && selectedTags.value.some((tag) => tile.tags.includes(tag)))
|
||||
return matchesSearch && matchesTags
|
||||
})
|
||||
|
||||
filteredTiles.forEach((tile) => {
|
||||
const parentGroup = groups.find((group) => areTilesRelated(group.parent, tile))
|
||||
if (parentGroup && parentGroup.parent.id !== tile.id) {
|
||||
parentGroup.children.push(tile)
|
||||
} else {
|
||||
groups.push({ parent: tile, children: [] })
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
const tileColorData = ref<Map<string, { r: number; g: number; b: number }>>(new Map())
|
||||
const tileEdgeData = ref<Map<string, number>>(new Map())
|
||||
|
||||
function areTilesRelated(tile1: Tile, tile2: Tile): boolean {
|
||||
const colorSimilarityThreshold = 30 // Adjust this value as needed
|
||||
const edgeComplexitySimilarityThreshold = 20 // Adjust this value as needed
|
||||
|
||||
const color1 = tileColorData.value.get(tile1.id)
|
||||
const color2 = tileColorData.value.get(tile2.id)
|
||||
const edge1 = tileEdgeData.value.get(tile1.id)
|
||||
const edge2 = tileEdgeData.value.get(tile2.id)
|
||||
|
||||
if (!color1 || !color2 || edge1 === undefined || edge2 === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
const colorDifference = Math.sqrt(Math.pow(color1.r - color2.r, 2) + Math.pow(color1.g - color2.g, 2) + Math.pow(color1.b - color2.b, 2))
|
||||
|
||||
const edgeComplexityDifference = Math.abs(edge1 - edge2)
|
||||
|
||||
const namePrefix1 = tile1.name.split('_')[0]
|
||||
const namePrefix2 = tile2.name.split('_')[0]
|
||||
|
||||
return colorDifference <= colorSimilarityThreshold && edgeComplexityDifference <= edgeComplexitySimilarityThreshold && namePrefix1 === namePrefix2
|
||||
}
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
if (selectedTags.value.includes(tag)) {
|
||||
selectedTags.value = selectedTags.value.filter((t) => t !== tag)
|
||||
} else {
|
||||
selectedTags.value.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
function processTile(tile: Tile) {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'Anonymous'
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
ctx!.drawImage(img, 0, 0, img.width, img.height)
|
||||
|
||||
const imageData = ctx!.getImageData(0, 0, canvas.width, canvas.height)
|
||||
tileColorData.value.set(tile.id, getDominantColor(imageData))
|
||||
tileEdgeData.value.set(tile.id, getEdgeComplexity(imageData))
|
||||
}
|
||||
img.src = `${config.server_endpoint}/textures/tiles/${tile.id}.png`
|
||||
}
|
||||
|
||||
function getDominantColor(imageData: ImageData) {
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0,
|
||||
total = 0
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
if (imageData.data[i + 3] > 0) {
|
||||
// Only consider non-transparent pixels
|
||||
r += imageData.data[i]
|
||||
g += imageData.data[i + 1]
|
||||
b += imageData.data[i + 2]
|
||||
total++
|
||||
}
|
||||
}
|
||||
return {
|
||||
r: Math.round(r / total),
|
||||
g: Math.round(g / total),
|
||||
b: Math.round(b / total)
|
||||
}
|
||||
}
|
||||
|
||||
function getEdgeComplexity(imageData: ImageData) {
|
||||
let edgePixels = 0
|
||||
for (let y = 0; y < imageData.height; y++) {
|
||||
for (let x = 0; x < imageData.width; x++) {
|
||||
const i = (y * imageData.width + x) * 4
|
||||
if (imageData.data[i + 3] > 0 && (x === 0 || y === 0 || x === imageData.width - 1 || y === imageData.height - 1 || imageData.data[i - 1] === 0 || imageData.data[i + 7] === 0)) {
|
||||
edgePixels++
|
||||
}
|
||||
}
|
||||
}
|
||||
return edgePixels
|
||||
}
|
||||
|
||||
function getTileCategory(tile: Tile): string {
|
||||
return tileCategories.value.get(tile.id) || ''
|
||||
}
|
||||
|
||||
function openGroup(group: { parent: Tile; children: Tile[] }) {
|
||||
selectedGroup.value = group
|
||||
}
|
||||
|
||||
function closeGroup() {
|
||||
selectedGroup.value = null
|
||||
}
|
||||
|
||||
function selectTile(tile: string) {
|
||||
mapEditorStore.setSelectedTile(tile)
|
||||
}
|
||||
|
||||
function isActiveTile(tile: Tile): boolean {
|
||||
return mapEditorStore.selectedTile === tile.id
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isModalOpen.value = true
|
||||
gameStore.connection?.emit('gm:tile:list', {}, (response: Tile[]) => {
|
||||
mapEditorStore.setTileList(response)
|
||||
response.forEach((tile) => processTile(tile))
|
||||
})
|
||||
})
|
||||
</script>
|
178
src/components/gameMaster/mapEditor/partials/Toolbar.vue
Normal file
178
src/components/gameMaster/mapEditor/partials/Toolbar.vue
Normal file
@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="flex justify-center p-5">
|
||||
<div class="toolbar fixed bottom-0 left-0 m-3 rounded flex bg-gray solid border-solid border-2 border-gray-500 text-gray-300 p-1.5 px-3 h-10">
|
||||
<div ref="toolbar" class="tools flex gap-2.5" v-if="mapEditorStore.map">
|
||||
<button class="flex justify-center items-center min-w-10 p-0 relative" :class="{ 'border-0 border-b-[3px] border-solid border-cyan gap-2.5': mapEditorStore.tool === 'move' }" @click="handleClick('move')">
|
||||
<img class="invert w-5 h-5" src="/assets/icons/mapEditor/move.svg" alt="Move camera" /> <span class="h-5" :class="{ 'ml-2.5': mapEditorStore.tool !== 'move' }">(M)</span>
|
||||
</button>
|
||||
|
||||
<div class="w-px bg-cyan"></div>
|
||||
|
||||
<button class="flex justify-center items-center min-w-10 p-0 relative" :class="{ 'border-0 border-b-[3px] border-solid border-cyan gap-2.5': mapEditorStore.tool === 'pencil' }" @click="handleClick('pencil')">
|
||||
<img class="invert w-5 h-5" src="/assets/icons/mapEditor/pencil.svg" alt="Pencil" /> <span class="h-5" :class="{ 'ml-2.5': mapEditorStore.tool !== 'pencil' }">(P)</span>
|
||||
<div class="select" v-if="mapEditorStore.tool === 'pencil'">
|
||||
<div class="select-trigger group capitalize flex gap-3.5" :class="{ open: selectPencilOpen }">
|
||||
{{ mapEditorStore.drawMode.replace('_', ' ') }}
|
||||
<img class="group-[.open]:rotate-180 invert w-5 h-5 rotate-0 transition ease-in-out duration-200" src="/assets/icons/mapEditor/chevron.svg" alt="" />
|
||||
</div>
|
||||
<div class="flex flex-col absolute bottom-full mb-5 left-1/2 -translate-x-1/2 bg-gray rounded min-w-28 border border-gray-500 border-solid text-left" v-show="selectPencilOpen && mapEditorStore.tool === 'pencil'">
|
||||
<span class="py-2 px-2.5 relative hover:bg-cyan hover:text-white" @click="setDrawMode('tile')">
|
||||
Tile
|
||||
<div class="absolute w-4/5 left-1/2 -translate-x-1/2 bottom-0 h-px bg-cyan"></div>
|
||||
</span>
|
||||
<span class="py-2 px-2.5 relative hover:bg-cyan hover:text-white" @click="setDrawMode('map_object')">
|
||||
Map object
|
||||
<div class="absolute w-4/5 left-1/2 -translate-x-1/2 bottom-0 h-px bg-cyan"></div>
|
||||
</span>
|
||||
<span class="py-2 px-2.5 relative hover:bg-cyan hover:text-white" @click="setDrawMode('teleport')">
|
||||
Teleport
|
||||
<div class="absolute w-4/5 left-1/2 -translate-x-1/2 bottom-0 h-px bg-cyan"></div>
|
||||
</span>
|
||||
<span class="py-2 px-2.5 relative hover:bg-cyan hover:text-white" @click="setDrawMode('blocking tile')">Blocking tile</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="w-px bg-cyan"></div>
|
||||
|
||||
<button class="flex justify-center items-center min-w-10 p-0 relative" :class="{ 'border-0 border-b-[3px] border-solid border-cyan gap-2.5': mapEditorStore.tool === 'eraser' }" @click="handleClick('eraser')">
|
||||
<img class="invert w-5 h-5" src="/assets/icons/mapEditor/eraser.svg" alt="Eraser" /> <span class="h-5" :class="{ 'ml-2.5': mapEditorStore.tool !== 'eraser' }">(E)</span>
|
||||
<div class="select" v-if="mapEditorStore.tool === 'eraser'">
|
||||
<div class="select-trigger group capitalize flex gap-3.5" :class="{ open: selectEraserOpen }">
|
||||
{{ mapEditorStore.eraserMode.replace('_', ' ') }}
|
||||
<img class="group-[.open]:rotate-180 invert w-5 h-5 rotate-0 transition ease-in-out duration-200" src="/assets/icons/mapEditor/chevron.svg" />
|
||||
</div>
|
||||
<div class="flex flex-col absolute bottom-full mb-5 left-1/2 -translate-x-1/2 bg-gray rounded min-w-28 border border-gray-500 border-solid text-left" v-show="selectEraserOpen">
|
||||
<span class="py-2 px-2.5 relative hover:bg-cyan hover:text-white" @click="setEraserMode('tile')">
|
||||
Tile
|
||||
<div class="absolute w-4/5 left-1/2 -translate-x-1/2 bottom-0 h-px bg-cyan"></div>
|
||||
</span>
|
||||
<span class="py-2 px-2.5 relative hover:bg-cyan hover:text-white" @click="setEraserMode('map_object')">
|
||||
Map object
|
||||
<div class="absolute w-4/5 left-1/2 -translate-x-1/2 bottom-0 h-px bg-cyan"></div>
|
||||
</span>
|
||||
<span class="py-2 px-2.5 relative hover:bg-cyan hover:text-white" @click="setEraserMode('teleport')">
|
||||
Teleport
|
||||
<div class="absolute w-4/5 left-1/2 -translate-x-1/2 bottom-0 h-px bg-cyan"></div>
|
||||
</span>
|
||||
<span class="py-2 px-2.5 relative hover:bg-cyan hover:text-white" @click="setEraserMode('blocking tile')">Blocking tile</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="w-px bg-cyan"></div>
|
||||
|
||||
<button class="flex justify-center items-center min-w-10 p-0 relative" :class="{ 'border-0 border-b-[3px] border-solid border-cyan gap-2.5': mapEditorStore.tool === 'paint' }" @click="handleClick('paint')">
|
||||
<img class="invert w-5 h-5" src="/assets/icons/mapEditor/paint.svg" alt="Paint bucket" /> <span class="h-5" :class="{ 'ml-2.5': mapEditorStore.tool !== 'paint' }">(B)</span>
|
||||
</button>
|
||||
|
||||
<div class="w-px bg-cyan"></div>
|
||||
|
||||
<button class="flex justify-center items-center min-w-10 p-0 relative" @click="handleClick('settings')" v-if="mapEditorStore.map"><img class="invert w-5 h-5" src="/assets/icons/mapEditor/gear.svg" alt="Map settings" /> <span class="h-5 ml-2.5">(Z)</span></button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar fixed bottom-0 right-0 m-3 rounded flex bg-gray solid border-solid border-2 border-gray-500 text-gray-300 p-1.5 px-3 h-10 space-x-2">
|
||||
<button class="btn-cyan px-3.5" @click="() => mapEditorStore.toggleMapListModal()">Load</button>
|
||||
<button class="btn-cyan px-3.5" @click="() => emit('save')" v-if="mapEditorStore.map">Save</button>
|
||||
<button class="btn-cyan px-3.5" @click="() => emit('clear')" v-if="mapEditorStore.map">Clear</button>
|
||||
<button class="btn-cyan px-3.5" @click="() => mapEditorStore.toggleActive()">Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMapEditorStore } from '@/stores/mapEditorStore'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const mapEditorStore = useMapEditorStore()
|
||||
|
||||
const emit = defineEmits(['save', 'clear'])
|
||||
|
||||
// track when clicked outside of toolbar items
|
||||
const toolbar = ref(null)
|
||||
|
||||
// track select state
|
||||
let selectPencilOpen = ref(false)
|
||||
let selectEraserOpen = ref(false)
|
||||
|
||||
// drawMode
|
||||
function setDrawMode(value: string) {
|
||||
mapEditorStore.isTileListModalShown = value === 'tile'
|
||||
mapEditorStore.isMapObjectListModalShown = value === 'map_object'
|
||||
|
||||
mapEditorStore.setDrawMode(value)
|
||||
selectPencilOpen.value = false
|
||||
}
|
||||
|
||||
// drawMode
|
||||
function setEraserMode(value: string) {
|
||||
mapEditorStore.setEraserMode(value)
|
||||
selectEraserOpen.value = false
|
||||
}
|
||||
|
||||
function handleClick(tool: string) {
|
||||
if (tool === 'settings') {
|
||||
mapEditorStore.toggleSettingsModal()
|
||||
} else {
|
||||
mapEditorStore.setTool(tool)
|
||||
}
|
||||
|
||||
selectPencilOpen.value = tool === 'pencil' ? !selectPencilOpen.value : false
|
||||
selectEraserOpen.value = tool === 'eraser' ? !selectEraserOpen.value : false
|
||||
}
|
||||
|
||||
function cycleToolMode(tool: 'pencil' | 'eraser') {
|
||||
const modes = ['tile', 'object', 'teleport', 'blocking tile']
|
||||
const currentMode = tool === 'pencil' ? mapEditorStore.drawMode : mapEditorStore.eraserMode
|
||||
const currentIndex = modes.indexOf(currentMode)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
const nextMode = modes[nextIndex]
|
||||
|
||||
if (tool === 'pencil') {
|
||||
setDrawMode(nextMode)
|
||||
} else {
|
||||
setEraserMode(nextMode)
|
||||
}
|
||||
}
|
||||
|
||||
function initKeyShortcuts(event: KeyboardEvent) {
|
||||
// Check if map is set
|
||||
if (!mapEditorStore.map) return
|
||||
|
||||
// prevent if focused on composables
|
||||
if (document.activeElement?.tagName === 'INPUT') return
|
||||
|
||||
const keyActions: { [key: string]: string } = {
|
||||
m: 'move',
|
||||
p: 'pencil',
|
||||
e: 'eraser',
|
||||
b: 'paint',
|
||||
z: 'settings'
|
||||
}
|
||||
|
||||
if (keyActions.hasOwnProperty(event.key)) {
|
||||
const tool = keyActions[event.key]
|
||||
if ((tool === 'pencil' || tool === 'eraser') && mapEditorStore.tool === tool) {
|
||||
cycleToolMode(tool)
|
||||
} else {
|
||||
handleClick(tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside() {
|
||||
selectPencilOpen.value = false
|
||||
selectEraserOpen.value = false
|
||||
}
|
||||
onClickOutside(toolbar, handleClickOutside)
|
||||
|
||||
onMounted(() => {
|
||||
addEventListener('keydown', initKeyShortcuts)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListener('keydown', initKeyShortcuts)
|
||||
})
|
||||
</script>
|
Reference in New Issue
Block a user