Rebuilt side panel for object & tile lists
Reorganised file structure
This commit is contained in:
@ -73,7 +73,7 @@ input {
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply px-4 py-2.5 text-base leading-5 bg-gray border border-solid border-gray-500 rounded text-gray-300;
|
||||
@apply px-4 py-2.5 text-base leading-5 bg-gray border border-solid border-gray-500 rounded text-gray-300 font-default;
|
||||
&:focus-visible {
|
||||
@apply outline-none border-cyan rounded bg-gray-900;
|
||||
}
|
||||
@ -88,6 +88,12 @@ input {
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
&.input-field {
|
||||
@apply appearance-none bg-[url('/assets/icons/mapEditor/dropdown-chevron.svg')] bg-no-repeat bg-[calc(100%_-_10px)_center] bg-[length:20px] text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field-full {
|
||||
@apply w-full flex flex-col mb-5;
|
||||
label {
|
||||
|
68
src/components/gameMaster/mapEditor/partials/ListPanel.vue
Normal file
68
src/components/gameMaster/mapEditor/partials/ListPanel.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="isOpen">
|
||||
<div class="flex flex-col gap-2.5 p-2.5">
|
||||
<div class="relative flex">
|
||||
<img src="/assets/icons/mapEditor/search.svg" class="w-4 h-4 py-0.5 absolute top-1/2 -translate-y-1/2 left-2.5" alt="Search icon" />
|
||||
<label class="mb-1.5 font-titles hidden" for="search">Search</label>
|
||||
<input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<select class="input-field w-full" name="lists" v-model="lists">
|
||||
<option value="tiles">Tiles</option>
|
||||
<option value="objects">Objects</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full overflow-auto relative border-0 border-t border-solid border-gray-500 p-2.5">
|
||||
<TileList v-if="lists === 'tiles'" />
|
||||
<ObjectList v-if="lists === 'objects'" />
|
||||
</div>
|
||||
<div class="flex flex-col h-40 gap-2.5 p-3.5 border-t border-0 border-solid border-gray-500">
|
||||
<span>Tags:</span>
|
||||
<div class="flex grow items-center flex-wrap gap-1.5 overflow-auto">
|
||||
<span class="m-auto">No tags selected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import config from '@/application/config'
|
||||
import type { Tile } from '@/application/types'
|
||||
import { TileStorage } from '@/storage/storages'
|
||||
import { liveQuery } from 'dexie'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import TileList from '@/components/gameMaster/mapEditor/partials/lists/TileList.vue'
|
||||
import ObjectList from '@/components/gameMaster/mapEditor/partials/lists/MapObjectList.vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const tileStorage = new TileStorage()
|
||||
const searchQuery = ref('')
|
||||
const tiles = ref<Tile[]>([])
|
||||
const lists = ref<'tiles' | 'objects'>('tiles')
|
||||
|
||||
defineExpose({
|
||||
open: () => (isOpen.value = true),
|
||||
close: () => (isOpen.value = false),
|
||||
toggle: () => (isOpen.value = !isOpen.value)
|
||||
})
|
||||
|
||||
let subscription: any = null
|
||||
|
||||
onMounted(() => {
|
||||
subscription = liveQuery(() => tileStorage.liveQuery()).subscribe({
|
||||
next: (result) => {
|
||||
tiles.value = result
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to fetch tiles:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
})
|
||||
</script>
|
@ -1,132 +0,0 @@
|
||||
<template>
|
||||
<div class="absolute border-0 border-l-2 border-solid border-gray-500 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" :style="{ width: width + 'px' }" v-if="isOpen">
|
||||
<div class="absolute left-0 top-0 w-1 h-full cursor-ew-resize hover:bg-cyan transition-colors duration-200 z-50" @mousedown.stop="startResize"></div>
|
||||
<div class="relative z-10 p-2.5 border-solid border-0 border-b border-gray-500">
|
||||
<h3 class="text-lg text-white">Map objects</h3>
|
||||
</div>
|
||||
<div class="overflow-hidden grow relative">
|
||||
<div class="absolute w-full h-full top-0 left-0">
|
||||
<div class="relative z-10 h-full">
|
||||
<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-[calc(100%_-_170px)] p-4 pb-24">
|
||||
<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 rounded max-w-full"
|
||||
:src="`${config.server_endpoint}/textures/map_objects/${mapObject.id}.png`"
|
||||
alt="Object"
|
||||
@click="mapEditor.setSelectedMapObject(mapObject)"
|
||||
:class="{
|
||||
'cursor-pointer transition-all duration-300': true,
|
||||
'border-cyan shadow-lg': mapEditor.selectedMapObject.value?.id === mapObject.id,
|
||||
'border-transparent hover:border-gray-300': mapEditor.selectedMapObject.value?.id !== mapObject.id
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import config from '@/application/config'
|
||||
import type { MapObject } from '@/application/types'
|
||||
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||
import { MapObjectStorage } from '@/storage/storages'
|
||||
import { liveQuery } from 'dexie'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const mapObjectStorage = new MapObjectStorage()
|
||||
const isModalOpen = ref(false)
|
||||
const mapEditor = useMapEditorComposable()
|
||||
const searchQuery = ref('')
|
||||
const selectedTags = ref<string[]>([])
|
||||
const mapObjectList = ref<MapObject[]>([])
|
||||
const width = ref(320)
|
||||
const minWidth = 320
|
||||
const maxWidth = 800
|
||||
|
||||
const startResize = (event: MouseEvent) => {
|
||||
const startX = event.clientX
|
||||
const startWidth = width.value
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = startX - e.clientX
|
||||
const newWidth = Math.min(Math.max(startWidth + delta, minWidth), maxWidth)
|
||||
width.value = newWidth
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: () => (isOpen.value = true),
|
||||
close: () => (isOpen.value = false),
|
||||
toggle: () => (isOpen.value = !isOpen.value)
|
||||
})
|
||||
|
||||
const uniqueTags = computed(() => {
|
||||
const allTags = mapObjectList.value.flatMap((obj) => obj.tags || [])
|
||||
return Array.from(new Set(allTags))
|
||||
})
|
||||
|
||||
const filteredMapObjects = computed(() => {
|
||||
return mapObjectList.value.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)
|
||||
}
|
||||
}
|
||||
|
||||
let subscription: any = null
|
||||
|
||||
onMounted(() => {
|
||||
isModalOpen.value = true
|
||||
subscription = liveQuery(() => mapObjectStorage.liveQuery()).subscribe({
|
||||
next: (result) => {
|
||||
mapObjectList.value = result
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to fetch tiles:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
})
|
||||
</script>
|
@ -1,176 +0,0 @@
|
||||
<template>
|
||||
<div class="absolute border-0 border-l-2 border-solid border-gray-500 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" :style="{ width: width + 'px' }" v-if="isOpen">
|
||||
<div class="absolute left-0 top-0 w-1 h-full cursor-ew-resize hover:bg-cyan transition-colors duration-200 z-20" @mousedown.stop="startDragging"></div>
|
||||
<div class="relative z-10 p-2.5 border-solid border-0 border-b border-gray-500">
|
||||
<h3 class="text-lg text-white">Tiles</h3>
|
||||
</div>
|
||||
<div class="overflow-y-auto grow relative">
|
||||
<div class="h-full w-full">
|
||||
<div class="relative z-10 h-full">
|
||||
<div class="grid auto-rows-max gap-2 justify-items-center p-4" style="grid-template-columns: repeat(auto-fill, minmax(80px, 1fr))">
|
||||
<template v-if="!selectedGroup">
|
||||
<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 rounded cursor-pointer transition-all duration-300"
|
||||
:src="`${config.server_endpoint}/textures/tiles/${group.parent.id}.png`"
|
||||
:alt="group.parent.name"
|
||||
@click="openGroup(group)"
|
||||
@load="() => tileProcessor.processTile(group.parent)"
|
||||
:class="{
|
||||
'border-cyan shadow-lg': 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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="col-span-full mb-4 flex items-center">
|
||||
<button @click="closeGroup" class="flex items-center text-white hover:text-cyan transition-colors duration-200">
|
||||
<img class="invert w-5 h-5 rotate-90 mr-2" src="/assets/icons/mapEditor/chevron.svg" alt="Back" />
|
||||
Back to groups
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="tile in [selectedGroup.parent, ...selectedGroup.children]" :key="tile.id" class="flex flex-col items-center justify-center relative">
|
||||
<img
|
||||
class="max-w-full max-h-full border-2 border-solid rounded cursor-pointer transition-all duration-300"
|
||||
:src="`${config.server_endpoint}/textures/tiles/${tile.id}.png`"
|
||||
:alt="tile.name"
|
||||
@click="selectTile(tile.id)"
|
||||
@load="() => tileProcessor.processTile(tile)"
|
||||
:class="{
|
||||
'border-cyan shadow-lg': isActiveTile(tile),
|
||||
'border-transparent hover:border-gray-300': !isActiveTile(tile)
|
||||
}"
|
||||
/>
|
||||
<span class="text-xs mt-1">{{ getTileCategory(tile) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import config from '@/application/config'
|
||||
import type { Tile } from '@/application/types'
|
||||
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||
import { useTileProcessingComposable } from '@/composables/useTileProcessingComposable'
|
||||
import { TileStorage } from '@/storage/storages'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const width = ref(320)
|
||||
const isDragging = ref(false)
|
||||
|
||||
const tileStorage = new TileStorage()
|
||||
const mapEditor = useMapEditorComposable()
|
||||
const tileProcessor = useTileProcessingComposable()
|
||||
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 tiles = ref<Tile[]>([])
|
||||
|
||||
function startDragging(event: MouseEvent) {
|
||||
isDragging.value = true
|
||||
const startX = event.clientX
|
||||
const startWidth = width.value
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (isDragging.value) {
|
||||
const deltaX = startX - e.clientX
|
||||
width.value = Math.max(320, Math.min(800, startWidth + deltaX))
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: () => (isOpen.value = true),
|
||||
close: () => (isOpen.value = false),
|
||||
toggle: () => (isOpen.value = !isOpen.value)
|
||||
})
|
||||
|
||||
const uniqueTags = computed(() => {
|
||||
const allTags = tiles.value.flatMap((tile) => tile.tags || [])
|
||||
return Array.from(new Set(allTags))
|
||||
})
|
||||
|
||||
const groupedTiles = computed(() => {
|
||||
const groups: { parent: Tile; children: Tile[] }[] = []
|
||||
const filteredTiles = tiles.value.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) => tileProcessor.areTilesRelated(group.parent, tile))
|
||||
if (parentGroup && parentGroup.parent.id !== tile.id) {
|
||||
parentGroup.children.push(tile)
|
||||
} else {
|
||||
groups.push({ parent: tile, children: [] })
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
if (selectedTags.value.includes(tag)) {
|
||||
selectedTags.value = selectedTags.value.filter((t) => t !== tag)
|
||||
} else {
|
||||
selectedTags.value.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
mapEditor.setSelectedTile(tile)
|
||||
}
|
||||
|
||||
function isActiveTile(tile: Tile): boolean {
|
||||
return mapEditor.selectedTile.value === tile.id
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
tiles.value = await tileStorage.getAll()
|
||||
const initialBatchSize = 20
|
||||
const initialTiles = tiles.value.slice(0, initialBatchSize)
|
||||
initialTiles.forEach((tile) => tileProcessor.processTile(tile))
|
||||
|
||||
// Process remaining tiles in background
|
||||
setTimeout(() => {
|
||||
tiles.value.slice(initialBatchSize).forEach((tile) => tileProcessor.processTile(tile))
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
tileProcessor.cleanup()
|
||||
})
|
||||
</script>
|
@ -76,7 +76,7 @@
|
||||
<input @change="handleCheck" v-model="checkboxValue" type="checkbox" />
|
||||
</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">
|
||||
<div class="toolbar fixed bottom-0 right-80 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="() => emit('open-maps')">Load</button>
|
||||
<button class="btn-cyan px-3.5" @click="() => emit('save')" v-if="mapEditor.currentMap.value">Save</button>
|
||||
<button class="btn-cyan px-3.5" @click="() => emit('clear')" v-if="mapEditor.currentMap.value">Clear</button>
|
||||
@ -93,7 +93,7 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const mapEditor = useMapEditorComposable()
|
||||
|
||||
const emit = defineEmits(['save', 'clear', 'open-maps', 'open-settings', 'close-editor', 'open-tile-list', 'open-map-object-list', 'close-lists'])
|
||||
const emit = defineEmits(['save', 'clear', 'open-maps', 'open-settings', 'close-editor', 'open-lists', 'close-lists'])
|
||||
|
||||
// track when clicked outside of toolbar items
|
||||
const toolbar = ref(null)
|
||||
@ -113,8 +113,7 @@ defineExpose({ tileListShown, mapObjectListShown })
|
||||
function setDrawMode(value: string) {
|
||||
if (mapEditor.tool.value === 'paint' || mapEditor.tool.value === 'pencil' || mapEditor.tool.value === 'eraser') {
|
||||
emit('close-lists')
|
||||
if (value === 'tile') emit('open-tile-list')
|
||||
if (value === 'map_object') emit('open-map-object-list')
|
||||
if (value === 'tile' || value === 'map_object') emit('open-lists')
|
||||
}
|
||||
|
||||
mapEditor.setDrawMode(value)
|
||||
|
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<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 rounded max-w-full"
|
||||
:src="`${config.server_endpoint}/textures/map_objects/${mapObject.id}.png`"
|
||||
alt="Object"
|
||||
@click="mapEditor.setSelectedMapObject(mapObject)"
|
||||
:class="{
|
||||
'cursor-pointer transition-all duration-300': true,
|
||||
'border-cyan shadow-lg': mapEditor.selectedMapObject.value?.id === mapObject.id,
|
||||
'border-transparent hover:border-gray-300': mapEditor.selectedMapObject.value?.id !== mapObject.id
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import config from '@/application/config'
|
||||
import type { MapObject } from '@/application/types'
|
||||
import Modal from '@/components/utilities/Modal.vue'
|
||||
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||
import { MapObjectStorage } from '@/storage/storages'
|
||||
import { liveQuery } from 'dexie'
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const mapObjectStorage = new MapObjectStorage()
|
||||
const isModalOpen = ref(false)
|
||||
const mapEditor = useMapEditorComposable()
|
||||
const searchQuery = ref('')
|
||||
const selectedTags = ref<string[]>([])
|
||||
const mapObjectList = ref<MapObject[]>([])
|
||||
|
||||
defineExpose({
|
||||
open: () => (isOpen.value = true),
|
||||
close: () => (isOpen.value = false),
|
||||
toggle: () => (isOpen.value = !isOpen.value)
|
||||
})
|
||||
|
||||
const uniqueTags = computed(() => {
|
||||
const allTags = mapObjectList.value.flatMap((obj) => obj.tags || [])
|
||||
return Array.from(new Set(allTags))
|
||||
})
|
||||
|
||||
const filteredMapObjects = computed(() => {
|
||||
return mapObjectList.value.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)
|
||||
}
|
||||
}
|
||||
|
||||
let subscription: any = null
|
||||
|
||||
onMounted(() => {
|
||||
isModalOpen.value = true
|
||||
subscription = liveQuery(() => mapObjectStorage.liveQuery()).subscribe({
|
||||
next: (result) => {
|
||||
mapObjectList.value = result
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to fetch tiles:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
})
|
||||
</script>
|
232
src/components/gameMaster/mapEditor/partials/lists/TileList.vue
Normal file
232
src/components/gameMaster/mapEditor/partials/lists/TileList.vue
Normal file
@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="h-full" v-if="!selectedGroup">
|
||||
<div class="grid grid-cols-4 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 rounded 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': 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 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-4 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 rounded 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': 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 rounded 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': isActiveTile(childTile),
|
||||
'border-transparent hover:border-gray-300': !isActiveTile(childTile)
|
||||
}"
|
||||
/>
|
||||
<span class="text-xs mt-1">{{ getTileCategory(childTile) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import config from '@/application/config'
|
||||
import type { Tile } from '@/application/types'
|
||||
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||
import { TileStorage } from '@/storage/storages'
|
||||
import { liveQuery } from 'dexie'
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const tileStorage = new TileStorage()
|
||||
const mapEditor = useMapEditorComposable()
|
||||
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 tiles = ref<Tile[]>([])
|
||||
|
||||
defineExpose({
|
||||
open: () => (isOpen.value = true),
|
||||
close: () => (isOpen.value = false),
|
||||
toggle: () => (isOpen.value = !isOpen.value)
|
||||
})
|
||||
|
||||
const uniqueTags = computed(() => {
|
||||
const allTags = tiles.value.flatMap((tile) => tile.tags || [])
|
||||
return Array.from(new Set(allTags))
|
||||
})
|
||||
|
||||
const groupedTiles = computed(() => {
|
||||
const groups: { parent: Tile; children: Tile[] }[] = []
|
||||
const filteredTiles = tiles.value.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) {
|
||||
mapEditor.setSelectedTile(tile)
|
||||
}
|
||||
|
||||
function isActiveTile(tile: Tile): boolean {
|
||||
return mapEditor.selectedTile.value === tile.id
|
||||
}
|
||||
|
||||
let subscription: any = null
|
||||
|
||||
onMounted(() => {
|
||||
subscription = liveQuery(() => tileStorage.liveQuery()).subscribe({
|
||||
next: (result) => {
|
||||
tiles.value = result
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to fetch tiles:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
})
|
||||
</script>
|
@ -12,14 +12,11 @@
|
||||
@open-maps="mapModal?.open"
|
||||
@open-settings="mapSettingsModal?.open"
|
||||
@close-editor="mapEditor.toggleActive"
|
||||
@close-lists="tileList?.close"
|
||||
@closeLists="objectList?.close"
|
||||
@open-tile-list="tileList?.open"
|
||||
@open-map-object-list="objectList?.open"
|
||||
@close-lists="list?.close"
|
||||
@open-lists="list?.open"
|
||||
/>
|
||||
<MapList ref="mapModal" @open-create-map="mapSettingsModal?.open" />
|
||||
<TileList ref="tileList" />
|
||||
<ObjectList ref="objectList" />
|
||||
<ListPanel ref="list" />
|
||||
<MapSettings ref="mapSettingsModal" />
|
||||
<TeleportModal ref="teleportModal" />
|
||||
</div>
|
||||
@ -34,10 +31,9 @@ import 'phaser'
|
||||
import type { Map as MapT } from '@/application/types'
|
||||
import Map from '@/components/gameMaster/mapEditor/Map.vue'
|
||||
import MapList from '@/components/gameMaster/mapEditor/partials/MapList.vue'
|
||||
import ObjectList from '@/components/gameMaster/mapEditor/partials/MapObjectList.vue'
|
||||
import ListPanel from '@/components/gameMaster/mapEditor/partials/ListPanel.vue'
|
||||
import MapSettings from '@/components/gameMaster/mapEditor/partials/MapSettings.vue'
|
||||
import TeleportModal from '@/components/gameMaster/mapEditor/partials/TeleportModal.vue'
|
||||
import TileList from '@/components/gameMaster/mapEditor/partials/TileList.vue'
|
||||
import Toolbar from '@/components/gameMaster/mapEditor/partials/Toolbar.vue'
|
||||
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||
import { loadAllTileTextures } from '@/services/mapService'
|
||||
@ -51,8 +47,7 @@ const mapEditor = useMapEditorComposable()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const mapModal = useTemplateRef('mapModal')
|
||||
const tileList = useTemplateRef('tileList')
|
||||
const objectList = useTemplateRef('objectList')
|
||||
const list = useTemplateRef('list')
|
||||
const mapSettingsModal = useTemplateRef('mapSettingsModal')
|
||||
|
||||
const isLoaded = ref(false)
|
||||
|
Reference in New Issue
Block a user