Compare commits

..

44 Commits

Author SHA1 Message Date
af5e2449b5 npm update 2025-03-27 21:20:11 +01:00
5392093d71 Sprite gen. work 2025-03-24 15:15:16 +01:00
27c775821a Minor improvements 2025-03-21 21:55:12 +01:00
0d5acd48ce npm update, npm run format 2025-03-21 21:52:37 +01:00
fc34a488d9 Bug fix for hot reload map objects upon saving in map editor 2025-03-21 21:50:31 +01:00
0b1e95f80f npm update 2025-03-21 02:03:58 +01:00
9f176aae45 Remove hidden class 2025-03-21 02:02:18 +01:00
4903a83c71 Merge remote-tracking branch 'origin/feature/depth-sort-fix' into feature/sprite-gen 2025-03-21 02:01:23 +01:00
ba3ed8c099 Sprite editor changes 2025-03-21 01:57:37 +01:00
2881d5f251 Fix for object depths and proper depths for rotated objects 2025-03-20 15:17:34 -05:00
32ca61cc50 Updated sprite editor component 2025-03-16 02:51:17 +01:00
6897ad0f1e Renamed file, removed redundant fields 2025-03-16 01:21:31 +01:00
db1766026e npm update, start updating components, removed obselete fields 2025-03-16 01:07:38 +01:00
3f28d85c30 npm update 2025-03-14 00:38:39 +01:00
a85ad94f15 Merge remote-tracking branch 'origin/main' into feature/depth-sort-fix 2025-03-14 00:31:45 +01:00
e6c684e066 Depth editing for map objects 2025-03-12 11:14:12 -05:00
142d991265 npm update 2025-03-11 23:54:06 +01:00
9e5dcc31fa Updated btn position 2025-03-08 01:40:55 +01:00
b5c5837105 npm update 2025-03-08 00:48:31 +01:00
57b503142e npm run format 2025-03-03 22:21:05 +01:00
208b58d05f npm update, moved component and updated it to composition API 2025-03-03 22:09:58 +01:00
87c04b6de5 Merge remote-tracking branch 'origin/main' into feature/depth-sort-fix 2025-03-03 21:53:39 +01:00
84f8db5e10 Split depth map object demo 2025-03-03 14:38:10 -06:00
84b34c4f85 Container based handling of placed objects 2025-02-22 18:00:37 -06:00
2495e14ece Prevent overflowing of hair img 2025-02-21 21:37:07 +01:00
febb924f75 Cleaned, improved character overview 2025-02-21 21:31:06 +01:00
dc2afba82b Create characters WIP 2025-02-21 03:14:46 +01:00
ed0a02a795 Comment gender selection 2025-02-21 02:20:21 +01:00
3670eb8736 Add reset btn and changed icon sizes 2025-02-21 02:10:49 +01:00
d85bf4846b Character hair refactor, enhancements 2025-02-21 02:02:36 +01:00
51e885cfdf #244: Allow nickname changes, fixed listening for notifications 2025-02-19 11:46:05 +01:00
ffc7efb17c Improve hair positioning 2025-02-19 11:21:46 +01:00
a0da0266d3 Revert logic, updated preview 2025-02-19 01:33:38 +01:00
2281c2c5e0 Mini cleanup 2025-02-19 01:07:27 +01:00
0e3a0e3dba Added Tauri config, updated character hair location logic (WIP) 2025-02-19 01:04:47 +01:00
ed992e1c2d Updated characters.vue 2025-02-18 18:27:35 +01:00
65b011982a Check if hair is set before executing logic 2025-02-18 18:02:50 +01:00
489c6c3ba0 #245 : Added color field to character hair 2025-02-18 17:54:31 +01:00
db650449ac Made save async. 2025-02-18 16:39:14 +01:00
2d7d598c94 #366 : Add storage logic to asset manager 2025-02-18 16:37:21 +01:00
7097eb1580 #366 : Add storage logic to asset manager 2025-02-18 16:37:15 +01:00
d51fbc8030 Map list small UI improvement 2025-02-17 19:23:30 +01:00
b5b6d0adcc Clear event tiles on map clear event 2025-02-17 15:22:24 +01:00
4b7b6e4885 Minor improvement for map saving 2025-02-17 14:49:03 +01:00
49 changed files with 6689 additions and 948 deletions

688
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,8 +19,8 @@
"axios": "^1.7.9",
"dexie": "^4.0.11",
"phaser": "^3.88.2",
"phavuer": "^0.16.5",
"phaser3-rex-plugins": "^1.80.13",
"phavuer": "^0.16.5",
"pinia": "^2.3.1",
"sharp": "^0.33.5",
"socket.io-client": "^4.8.1",
@ -31,6 +31,7 @@
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
"@tauri-apps/cli": "^2.2.7",
"@tsconfig/node20": "^20.1.4",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.14.11",

4
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

5149
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.0.4", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.2.4", features = [] }
tauri-plugin-log = "2.0.0-rc"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

16
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

37
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,37 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "noxious",
"version": "0.1.0",
"identifier": "com.noxious.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build-only"
},
"app": {
"windows": [
{
"title": "Noxious",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@ -37,7 +37,7 @@ export type MapObject = {
id: string
name: string
tags: string[]
pivotPoints: { x: number; y: number }[]
depthOffsets: number[]
originX: number
originY: number
frameRate: number
@ -151,8 +151,9 @@ export type CharacterType = {
export type CharacterHair = {
id: string
name: string
sprite?: Sprite
sprite: string | Sprite
gender: CharacterGender
color: string
isSelectable: boolean
}
@ -209,6 +210,8 @@ export enum CharacterEquipmentSlotType {
export type Sprite = {
id: string
name: string
width: number | null
height: number | null
createdAt: Date
updatedAt: Date
spriteActions: SpriteAction[]

View File

@ -124,7 +124,7 @@ button {
&.active,
&:hover {
@apply bg-red-400;
@apply bg-red-500;
}
}

View File

@ -2,8 +2,8 @@
<Container ref="characterContainer" :x="currentPositionX" :y="currentPositionY" :depth="isometricDepth">
<ChatBubble :mapCharacter="props.mapCharacter" />
<HealthBar :mapCharacter="props.mapCharacter" />
<CharacterHair :mapCharacter="props.mapCharacter" />
<Sprite ref="characterSprite" :origin-y="1" :flipX="isFlippedX" />
<CharacterHair :mapCharacter="props.mapCharacter" :flipX="isFlippedX" />
<Sprite ref="characterSprite" :flipX="isFlippedX" />
</Container>
</template>
@ -84,13 +84,14 @@ watch(
rotation: props.mapCharacter.character.rotation,
isAttacking: props.mapCharacter.isAttacking
}),
(oldValues, newValues) => {
async (oldValues, newValues) => {
handlePositionUpdate(oldValues, newValues)
}
)
onMounted(async () => {
await initializeSprite()
if (props.mapCharacter.character.id === gameStore.character!.id) {
scene.cameras.main.startFollow(characterContainer.value as Phaser.GameObjects.Container)
}

View File

@ -1,54 +1,63 @@
<template>
<Image v-bind="imageProps" v-if="gameStore.isTextureLoaded(texture)" />
<Image ref="image" v-if="hairSpriteId" />
</template>
<script lang="ts" setup>
import type { MapCharacter, Sprite as SpriteT } from '@/application/types'
import { loadSpriteTextures } from '@/services/textureService'
import { CharacterHairStorage, CharacterTypeStorage, SpriteStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { Image, useScene } from 'phavuer'
import { computed, onMounted, ref } from 'vue'
import { Image, refObj, useScene } from 'phavuer'
import { computed, onMounted, ref, watch } from 'vue'
const props = defineProps<{
mapCharacter: MapCharacter
}>()
const gameStore = useGameStore()
const scene = useScene()
const hairSpriteId = ref('')
const sprite = ref<SpriteT | null>(null)
const hairSprite = ref<SpriteT | null>(null)
const characterSpriteHeight = ref(0)
const image = refObj<Phaser.GameObjects.Image>()
const flipX = computed(() => [6, 0].includes(props.mapCharacter.character.rotation ?? 0))
const texture = computed(() => {
const { rotation } = props.mapCharacter.character
const direction = [0, 6].includes(rotation) ? 'back' : 'front'
const direction = flipX.value ? 'back' : 'front'
return `${hairSpriteId.value}-${direction}`
})
const isFlippedX = computed(() => [6, 4].includes(props.mapCharacter.character.rotation ?? 0))
const imageProps = computed(() => {
const direction = [0, 6].includes(props.mapCharacter.character.rotation ?? 0) ? 'back' : 'front'
const spriteAction = sprite.value?.spriteActions?.find((spriteAction) => spriteAction.action === direction)
return {
depth: 9999,
originX: Number(spriteAction?.originX) ?? 0,
originY: Number(spriteAction?.originY) ?? 0,
flipX: isFlippedX.value,
texture: texture.value
}
})
watch(
() => props.mapCharacter.character,
(newValue) => {
if (!image.value) return
image.value.setTexture(texture.value)
},
{ deep: true }
)
onMounted(async () => {
const characterHairStorage = new CharacterHairStorage()
const spriteId = await characterHairStorage.getSpriteId(props.mapCharacter.character.characterHair!)
if (!spriteId) return
if (!props.mapCharacter.character.characterType || !props.mapCharacter.character.characterHair) return
hairSpriteId.value = spriteId
const characterTypeStorage = new CharacterTypeStorage()
const characterHairStorage = new CharacterHairStorage()
const spriteStorage = new SpriteStorage()
sprite.value = await spriteStorage.getById(spriteId)
await loadSpriteTextures(scene, spriteId)
const characterType = await characterTypeStorage.getById(props.mapCharacter.character.characterType!)
if (!characterType) return
characterSpriteHeight.value = 100
hairSpriteId.value = await characterHairStorage.getSpriteId(props.mapCharacter.character.characterHair)
if (!hairSpriteId.value) return
hairSprite.value = await spriteStorage.getById(hairSpriteId.value)
if (!hairSprite.value) return
await loadSpriteTextures(scene, hairSpriteId.value)
if (!image.value) return
image.value.setOrigin(0.5, 2.15)
image.value.setTexture(texture.value)
image.value.setSize(30, 40)
})
</script>

View File

@ -0,0 +1,102 @@
<template>
<Zone :depth="baseDepth" :origin-x="mapObj?.originX" :origin-y="mapObj?.originY" :width="mapObj?.frameWidth" :height="mapObj?.frameHeight" :x="x" :y="y" />
</template>
<script setup lang="ts">
import type { MapObject, PlacedMapObject } from '@/application/types'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { calculateIsometricDepth } from '@/services/mapService'
import { onPreUpdate, useScene, Zone } from 'phavuer'
import { computed, onUnmounted } from 'vue'
interface Props {
obj?: PlacedMapObject
mapObj?: MapObject
x?: number
y?: number
}
const props = defineProps<Props>()
const mapEditor = useMapEditorComposable()
const scene = useScene()
const group = scene.add.group()
const partitionPoints = computed(() => {
if (!props.mapObj?.frameWidth || !props.mapObj?.depthOffsets.length) return []
const sliceCount = props.mapObj.depthOffsets.length
return Array.from({ length: sliceCount + 1 }, (_, i) => i * (props.mapObj!.frameWidth / sliceCount))
})
let baseDepth = 0
const createImagePartition = (startX: number, endX: number, depthOffset: number): void => {
if (!props.mapObj?.id) return
const img = scene.add.image(0, 0, props.mapObj.id)
img.setOrigin(props.mapObj.originX, props.mapObj.originY)
img.setCrop(startX, 0, endX, props.mapObj.frameHeight)
img.setDepth(baseDepth + depthOffset)
group.add(img)
}
const updateGroupProperties = (): void => {
if (!props.obj || !props.x || !props.y) return
const isMoving = mapEditor.movingPlacedObject.value?.id === props.obj.id
const isSelected = mapEditor.selectedMapObject.value?.id === props.obj.id
const isPlacedSelected = mapEditor.selectedPlacedObject.value?.id === props.obj.id
baseDepth = calculateIsometricDepth(props.obj.positionX, props.obj.positionY)
group.setXY(props.x, props.y)
group.setAlpha(isMoving || isSelected ? 0.5 : 1)
group.setTint(isPlacedSelected ? 0x00ff00 : 0xffffff)
group.setDepth(baseDepth)
}
const updateImageProperties = (): void => {
const orderedImages = group.getChildren() as Phaser.GameObjects.Image[]
orderedImages.forEach((image, index) => {
if (!props.obj || !props.mapObj || !props.x) return
image.flipX = props.obj.isRotated
if (props.obj.isRotated) {
const offsetNum = props.mapObj.depthOffsets.length
const xOffset = props.mapObj.frameWidth / offsetNum
image.x = props.x + (index < offsetNum / 2 ? -xOffset : xOffset)
image.setDepth(baseDepth - props.mapObj.depthOffsets[index])
} else {
image.x = props.x
image.setDepth(baseDepth + props.mapObj.depthOffsets[index])
}
})
}
onPreUpdate(() => {
updateGroupProperties()
updateImageProperties()
})
// Initial setup
const initializeGroup = (): void => {
if (!props.mapObj || !props.x || !props.y || !props.obj) return
baseDepth = calculateIsometricDepth(props.obj.positionX, props.obj.positionY)
group.setXY(props.x, props.y)
group.setOrigin(props.mapObj.originX, props.mapObj.originY)
const points = partitionPoints.value
for (let i = 0; i < points.length - 1; i++) {
createImagePartition(points[i], points[i + 1], props.mapObj.depthOffsets[i])
}
}
initializeGroup()
onUnmounted(() => {
group.destroy(true, true)
})
</script>

View File

@ -1,16 +1,15 @@
<template>
<Image v-if="mapObject && gameStore.isTextureLoaded(props.placedMapObject.mapObject as string)" v-bind="imageProps" />
<ImageGroup v-bind="groupProps" v-if="mapObject && gameStore.isTextureLoaded(props.placedMapObject.mapObject as string)" />
</template>
<script setup lang="ts">
import config from '@/application/config'
import type { MapObject, PlacedMapObject } from '@/application/types'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { calculateIsometricDepth, loadMapObjectTextures, tileToWorldXY } from '@/services/mapService'
import ImageGroup from '@/components/game/map/partials/ImageGroup.vue'
import { loadMapObjectTextures, tileToWorldXY } from '@/services/mapService'
import { MapObjectStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { Image, useScene } from 'phavuer'
import { computed, onMounted, ref, watch } from 'vue'
import { useScene } from 'phavuer'
import { computed, onMounted, ref } from 'vue'
import Tilemap = Phaser.Tilemaps.Tilemap
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
@ -24,10 +23,15 @@ const props = defineProps<{
const scene = useScene()
const gameStore = useGameStore()
const mapEditor = useMapEditorComposable()
const mapObject = ref<MapObject>()
const groupProps = computed(() => ({
...calculateObjectPlacement(props.placedMapObject),
mapObj: mapObject.value,
obj: props.placedMapObject
}))
async function initialize() {
if (!props.placedMapObject.mapObject) return
@ -44,6 +48,8 @@ async function initialize() {
const _mapObject = await mapObjectStorage.getById(props.placedMapObject.mapObject as string)
if (!_mapObject) return
console.log(_mapObject)
mapObject.value = _mapObject
await loadMapObjectTextures([_mapObject], scene)
@ -53,29 +59,11 @@ function calculateObjectPlacement(mapObj: PlacedMapObject): { x: number; y: numb
let position = tileToWorldXY(props.tileMapLayer, mapObj.positionX, mapObj.positionY)
return {
x: position.worldPositionX - mapObject.value!.frameWidth / 2,
y: position.worldPositionY - mapObject.value!.frameHeight / 2 + config.tile_size.height
x: position.worldPositionX,
y: position.worldPositionY
}
}
const imageProps = computed(() => ({
alpha: mapEditor.movingPlacedObject.value?.id == props.placedMapObject.id || mapEditor.selectedMapObject.value?.id == props.placedMapObject.id ? 0.5 : 1,
tint: mapEditor.selectedPlacedObject.value?.id == props.placedMapObject.id ? 0x00ff00 : 0xffffff,
depth: calculateIsometricDepth(props.placedMapObject.positionX, props.placedMapObject.positionY),
...calculateObjectPlacement(props.placedMapObject),
flipX: props.placedMapObject.isRotated,
texture: mapObject.value!.id,
originX: mapObject.value!.originX,
originY: mapObject.value!.originY
}))
watch(
() => mapEditor.refreshMapObject.value,
async () => {
await initialize()
}
)
onMounted(async () => {
await initialize()
})

View File

@ -20,12 +20,29 @@
</select>
</div>
<div class="form-field-full">
<div class="space-x-6 flex items-center">
<label for="color">Color</label>
<input v-model="characterColor" class="input-field" type="text" name="color" placeholder="Character Hair Color" />
<div class="h-[38px] w-[38px] rounded" :style="{ backgroundColor: characterColor }"></div>
</div>
</div>
<div class="form-field-half">
<label for="spriteId">Sprite</label>
<select v-model="characterSpriteId" class="input-field" name="spriteId">
<option disabled selected value="">Select sprite</option>
<option v-for="sprite in assetManagerStore.spriteList" :key="sprite.id" :value="sprite.id">{{ sprite.name }}</option>
</select>
</div>
<div class="form-field-half">
<label>Preview</label>
<div v-if="characterSpriteId" class="flex flex-col">
<div class="p-3 pb-5 min-h-32 block rounded-md default-border bg-gray-800">
<div class="flex items-center justify-center p-1 h-full bg-gray-700 rounded">
<img :src="config.server_endpoint + '/textures/sprites/' + characterSpriteId + '/front.png'" class="max-w-[200px] max-h-[200px] object-contain" />
</div>
</div>
</div>
</div>
<button class="btn-cyan px-4 py-1.5 min-w-24" type="submit">Save</button>
<button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.prevent="removeCharacterHair">Remove</button>
</form>
@ -34,49 +51,49 @@
</template>
<script setup lang="ts">
import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import type { CharacterGender, CharacterHair, Sprite } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import { socketManager } from '@/managers/SocketManager'
import { CharacterHairStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore()
const selectedCharacterHair = computed(() => assetManagerStore.selectedCharacterHair)
const characterName = ref('')
const characterGender = ref<CharacterGender>('MALE' as CharacterGender.MALE)
const characterColor = ref<string>('#000000')
const characterIsSelectable = ref<boolean>(false)
const characterSpriteId = ref<string | null | undefined>(null)
const genderOptions: CharacterGender[] = ['MALE' as CharacterGender.MALE, 'FEMALE' as CharacterGender.FEMALE]
if (!selectedCharacterHair.value) {
console.error('No character hair selected')
}
if (selectedCharacterHair.value) {
characterName.value = selectedCharacterHair.value.name
characterGender.value = selectedCharacterHair.value.gender
characterColor.value = selectedCharacterHair.value.color
characterIsSelectable.value = selectedCharacterHair.value.isSelectable
characterSpriteId.value = selectedCharacterHair.value.sprite?.id
}
function removeCharacterHair() {
async function removeCharacterHair() {
if (!selectedCharacterHair.value) return
socketManager.emit(SocketEvent.GM_CHARACTERHAIR_REMOVE, { id: selectedCharacterHair.value.id }, (response: boolean) => {
socketManager.emit(SocketEvent.GM_CHARACTERHAIR_REMOVE, { id: selectedCharacterHair.value.id }, async (response: boolean) => {
if (!response) {
console.error('Failed to remove character hair')
return
}
refreshCharacterHairList()
await downloadCache('character_hair', new CharacterHairStorage())
await refreshCharacterHairList()
})
}
function refreshCharacterHairList(unsetSelectedCharacterHair = true) {
async function refreshCharacterHairList(unsetSelectedCharacterHair = true) {
socketManager.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => {
assetManagerStore.setCharacterHairList(response)
@ -86,21 +103,24 @@ function refreshCharacterHairList(unsetSelectedCharacterHair = true) {
})
}
function saveCharacterHair() {
async function saveCharacterHair() {
const characterHairData = {
id: selectedCharacterHair.value!.id,
name: characterName.value,
gender: characterGender.value,
color: characterColor.value,
isSelectable: characterIsSelectable.value,
spriteId: characterSpriteId.value
}
socketManager.emit(SocketEvent.GM_CHARACTERHAIR_UPDATE, characterHairData, (response: boolean) => {
socketManager.emit(SocketEvent.GM_CHARACTERHAIR_UPDATE, characterHairData, async (response: boolean) => {
if (!response) {
console.error('Failed to save character type')
return
}
refreshCharacterHairList(false)
await downloadCache('character_hair', new CharacterHairStorage())
await refreshCharacterHairList(false)
})
}
@ -108,6 +128,7 @@ watch(selectedCharacterHair, (characterHair: CharacterHair | null) => {
if (!characterHair) return
characterName.value = characterHair.name
characterGender.value = characterHair.gender
characterColor.value = characterHair.color
characterIsSelectable.value = characterHair.isSelectable
characterSpriteId.value = characterHair.sprite?.id
})

View File

@ -42,12 +42,12 @@
<script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import type { CharacterGender, CharacterRace, CharacterType, Sprite } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import { socketManager } from '@/managers/SocketManager'
import { CharacterTypeStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore()
const selectedCharacterType = computed(() => assetManagerStore.selectedCharacterType)
@ -73,19 +73,21 @@ if (selectedCharacterType.value) {
characterSpriteId.value = selectedCharacterType.value.sprite?.id
}
function removeCharacterType() {
async function removeCharacterType() {
if (!selectedCharacterType.value) return
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_REMOVE, { id: selectedCharacterType.value.id }, (response: boolean) => {
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_REMOVE, { id: selectedCharacterType.value.id }, async (response: boolean) => {
if (!response) {
console.error('Failed to remove character type')
return
}
refreshCharacterTypeList()
await downloadCache('character_types', new CharacterTypeStorage())
await refreshCharacterTypeList()
})
}
function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
async function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => {
assetManagerStore.setCharacterTypeList(response)
@ -95,7 +97,7 @@ function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
})
}
function saveCharacterType() {
async function saveCharacterType() {
const characterTypeData = {
id: selectedCharacterType.value!.id,
name: characterName.value,
@ -105,12 +107,14 @@ function saveCharacterType() {
spriteId: characterSpriteId.value
}
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_UPDATE, characterTypeData, (response: boolean) => {
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_UPDATE, characterTypeData, async (response: boolean) => {
if (!response) {
console.error('Failed to save character type')
return
}
refreshCharacterTypeList(false)
await downloadCache('character_types', new CharacterTypeStorage())
await refreshCharacterTypeList(false)
})
}

View File

@ -1,21 +1,30 @@
<template>
<div class="h-full overflow-auto">
<div class="relative p-2.5 flex flex-col items-center justify-center h-72 rounded-md default-border bg-gray">
<div class="relative">
<img class="max-h-56" :src="`${config.server_endpoint}/textures/map_objects/${selectedMapObject?.id}.png`" :alt="'Object ' + selectedMapObject?.id" @click="addPivotPoint" ref="imageRef" />
<svg class="absolute bottom-1 left-0 w-full h-full pointer-events-none">
<line v-for="(_, index) in mapObjectPivotPoints.slice(0, -1)" :key="index" :x1="mapObjectPivotPoints[index].x" :y1="mapObjectPivotPoints[index].y" :x2="mapObjectPivotPoints[index + 1].x" :y2="mapObjectPivotPoints[index + 1].y" stroke="white" stroke-width="2" />
</svg>
<div
v-for="(point, index) in mapObjectPivotPoints"
:key="index"
class="absolute w-2 h-2 bg-white rounded-full cursor-move -translate-x-1.5 -translate-y-1.5 ring-2 ring-black"
:style="{ left: point.x + 'px', top: point.y + 'px' }"
@mousedown="startDragging(index, $event)"
@contextmenu.prevent="removePivotPoint(index)"
/>
<div class="grid grid-cols-[160px_auto_max-content] gap-12">
<div>
<input type="checkbox" checked v-model="showOrigin" /><label>Show Origin</label>
<br />
<input type="checkbox" checked v-model="showPartitionOverlay" /><label>Show Partitions</label>
</div>
<div class="relative w-fit h-fit">
<img class="max-h-56" :src="`${config.server_endpoint}/textures/map_objects/${selectedMapObject?.id}.png`" :alt="'Object ' + selectedMapObject?.id" ref="imageRef" />
<svg ref="svg" class="absolute top-0 left-0 w-full h-full inline-block pointer-events-none">
<circle v-if="showOrigin && svg" r="4" :cx="mapObjectOriginX * width" :cy="mapObjectOriginY * height" stroke="white" stroke-width="2" />
<rect v-if="showPartitionOverlay && svg" v-for="(offset, index) in mapObjectDepthOffsets" style="opacity: 0.5" stroke="red" :x="index * (width / mapObjectDepthOffsets.length)" :width="width / mapObjectDepthOffsets.length" :y="0" :height="height" />
</svg>
</div>
<div>
<button class="btn-cyan px-4 py-1.5 min-w-24" @click="mapObjectDepthOffsets.push(0)">Add Partition</button>
<p>Depth Offset</p>
<div class="text-white grid grid-cols-[120px_80px_auto] items-baseline gap-2" v-for="(offset, index) in mapObjectDepthOffsets">
<input class="input-field max-h-4 mt-2" type="number" :value="offset" @change="setPartitionDepth($event, index)" />
<button @click="mapObjectDepthOffsets.splice(index, 1)">Remove</button>
</div>
</div>
</div>
</div>
<div class="mt-5 block">
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveObject">
<div class="form-field-full">
@ -59,28 +68,32 @@
import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import type { MapObject } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import ChipsInput from '@/components/forms/ChipsInput.vue'
import { socketManager } from '@/managers/SocketManager'
import { MapObjectStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useElementSize } from '@vueuse/core'
import { Rectangle } from 'phavuer'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore()
const selectedMapObject = computed(() => assetManagerStore.selectedMapObject)
const svg = useTemplateRef('svg')
const { width, height } = useElementSize(svg)
const mapObjectName = ref('')
const mapObjectTags = ref<string[]>([])
const mapObjectPivotPoints = ref<Array<{ x: number; y: number }>>([])
const mapObjectDepthOffsets = ref<number[]>([])
const mapObjectOriginX = ref(0)
const mapObjectOriginY = ref(0)
const mapObjectFrameRate = ref(0)
const mapObjectFrameWidth = ref(0)
const mapObjectFrameHeight = ref(0)
const imageRef = ref<HTMLImageElement | null>(null)
const isDragging = ref(false)
const draggedPointIndex = ref(-1)
const showOrigin = ref(true)
const showPartitionOverlay = ref(true)
if (!selectedMapObject.value) {
console.error('No map mapObject selected')
@ -89,7 +102,7 @@ if (!selectedMapObject.value) {
if (selectedMapObject.value) {
mapObjectName.value = selectedMapObject.value.name
mapObjectTags.value = selectedMapObject.value.tags
mapObjectPivotPoints.value = selectedMapObject.value.pivotPoints
mapObjectDepthOffsets.value = selectedMapObject.value.depthOffsets
mapObjectOriginX.value = selectedMapObject.value.originX
mapObjectOriginY.value = selectedMapObject.value.originY
mapObjectFrameRate.value = selectedMapObject.value.frameRate
@ -97,17 +110,22 @@ if (selectedMapObject.value) {
mapObjectFrameHeight.value = selectedMapObject.value.frameHeight
}
function removeObject() {
socketManager.emit(SocketEvent.GM_MAPOBJECT_REMOVE, { mapObject: selectedMapObject.value?.id }, (response: boolean) => {
const setPartitionDepth = (event: any, idx: number) => (mapObjectDepthOffsets.value[idx] = Number.parseInt(event.target.value))
async function removeObject() {
if (!selectedMapObject.value) return
socketManager.emit(SocketEvent.GM_MAPOBJECT_REMOVE, { mapObjectId: selectedMapObject.value.id }, async (response: boolean) => {
if (!response) {
console.error('Failed to remove mapObject')
return
}
refreshObjectList()
await downloadCache('map_objects', new MapObjectStorage())
await refreshObjectList()
})
}
function refreshObjectList(unsetSelectedMapObject = true) {
async function refreshObjectList(unsetSelectedMapObject = true) {
socketManager.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => {
assetManagerStore.setMapObjectList(response)
@ -117,31 +135,32 @@ function refreshObjectList(unsetSelectedMapObject = true) {
})
}
function saveObject() {
async function saveObject() {
if (!selectedMapObject.value) {
console.error('No mapObject selected')
return
}
socketManager.emit(
SocketEvent.GM_MAPOBJECT_UPDATE,
{
id: selectedMapObject.value.id,
name: mapObjectName.value,
tags: mapObjectTags.value,
pivotPoints: mapObjectPivotPoints.value,
depthOffsets: mapObjectDepthOffsets.value,
originX: mapObjectOriginX.value,
originY: mapObjectOriginY.value,
frameRate: mapObjectFrameRate.value,
frameWidth: mapObjectFrameWidth.value,
frameHeight: mapObjectFrameHeight.value
},
(response: boolean) => {
async (response: boolean) => {
if (!response) {
console.error('Failed to save mapObject')
return
}
refreshObjectList(false)
await downloadCache('map_objects', new MapObjectStorage())
await refreshObjectList(false)
}
)
}
@ -150,7 +169,7 @@ watch(selectedMapObject, (mapObject: MapObject | null) => {
if (!mapObject) return
mapObjectName.value = mapObject.name
mapObjectTags.value = mapObject.tags
mapObjectPivotPoints.value = mapObject.pivotPoints
mapObjectDepthOffsets.value = mapObject.depthOffsets
mapObjectOriginX.value = mapObject.originX
mapObjectOriginY.value = mapObject.originY
mapObjectFrameRate.value = mapObject.frameRate
@ -162,43 +181,29 @@ onMounted(() => {
if (!selectedMapObject.value) return
})
function addPivotPoint(event: MouseEvent) {
if (!imageRef.value) return
// Max 2
if (mapObjectPivotPoints.value.length >= 2) return
const rect = imageRef.value.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
mapObjectPivotPoints.value.push({ x, y })
}
function startDragging(index: number, event: MouseEvent) {
isDragging.value = true
draggedPointIndex.value = index
const moveHandler = (e: MouseEvent) => {
if (!isDragging.value || !imageRef.value) return
const rect = imageRef.value.getBoundingClientRect()
mapObjectPivotPoints.value[draggedPointIndex.value] = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
const upHandler = () => {
isDragging.value = false
draggedPointIndex.value = -1
window.removeEventListener('mousemove', moveHandler)
window.removeEventListener('mouseup', upHandler)
}
window.addEventListener('mousemove', moveHandler)
window.addEventListener('mouseup', upHandler)
}
function removePivotPoint(index: number) {
mapObjectPivotPoints.value.splice(index, 1)
}
// function startDragging(index: number, event: MouseEvent) {
// isDragging.value = true
// draggedPointIndex.value = index
//
// const moveHandler = (e: MouseEvent) => {
// if (!isDragging.value || !imageRef.value) return
// const rect = imageRef.value.getBoundingClientRect()
// mapObjectPivotPoints.value[draggedPointIndex.value] = {
// x: e.clientX - rect.left,
// y: e.clientY - rect.top
// }
// }
//
// const upHandler = () => {
// isDragging.value = false
// draggedPointIndex.value = -1
// window.removeEventListener('mousemove', moveHandler)
// window.removeEventListener('mouseup', upHandler)
// }
//
// window.addEventListener('mousemove', moveHandler)
// window.addEventListener('mouseup', upHandler)
// }
onBeforeUnmount(() => {
assetManagerStore.setSelectedMapObject(null)

View File

@ -1,12 +1,11 @@
<template>
<div class="h-full overflow-auto">
<div class="relative flex flex-col">
<div class="flex flex-wrap gap-2 p-2.5 rounded-md default-border bg-gray">
<div class="flex flex-wrap gap-2 p-2.5 rounded-md default-border bg-gray mb-4">
<div class="w-full flex flex-col">
<label class="mb-1.5 font-titles" for="name">Name</label>
<input v-model="spriteName" class="input-field" type="text" name="name" placeholder="New sprite" />
</div>
<div class="w-full flex gap-2 mt-2 pb-4 relative">
<button class="btn-cyan px-4 py-2 flex-1 sm:flex-none sm:min-w-24" type="button" @click.prevent="saveSprite">Save</button>
<button class="btn-red px-4 py-2 flex-1 sm:flex-none sm:min-w-24" type="button" @click.prevent="deleteSprite">Delete</button>
@ -15,53 +14,39 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button class="btn-cyan px-4" type="button" @click.prevent="addNewImage">New action</button>
</div>
</div>
<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 items-center">
{{ action.action }}
<div class="ml-auto space-x-2">
<button class="btn-cyan px-4 py-1.5 min-w-24" type="button" @click.stop.prevent="openPreviewModal(action)">View</button>
<button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.stop.prevent="() => spriteActions.splice(spriteActions.indexOf(action), 1)">Delete</button>
</div>
<div v-for="action in spriteActions" :key="action.id">
<div class="flex flex-wrap gap-3 mb-3">
<div v-for="(image, index) in action.sprites" :key="index" class="h-20 w-20 p-4 bg-gray-300 bg-opacity-50 rounded text-center relative group">
<img :src="image.url" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" @load="updateImageDimensions($event, index)" />
<div v-if="imageDimensions[index]" class="absolute bottom-1 right-1 bg-black/50 text-white text-xs px-1 py-0.5 rounded transition-opacity font-default">{{ imageDimensions[index].width }}x{{ imageDimensions[index].height }}</div>
</div>
</template>
<template #content>
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveSprite">
<div class="form-field-full">
<label for="action">Action</label>
<input v-model="action.action" class="input-field" type="text" name="action" placeholder="Action" />
</div>
<div class="form-field-half">
<label for="origin-x">Origin X</label>
<input v-model.number="action.originX" 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 v-model.number="action.originY" class="input-field" type="number" step="any" name="origin-y" placeholder="Origin Y" />
</div>
<div class="form-field-full">
<label for="frame-speed">Frame rate</label>
<input v-model.number="action.frameRate" class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame rate" />
</div>
<div class="form-field-full">
<SpriteActionsInput v-model="action.sprites" @tempOffsetChange="(index, offset) => handleTempOffsetChange(action, index, offset)" />
</div>
</form>
</template>
</Accordion>
<SpritePreview
v-if="selectedAction"
:sprites="selectedAction.sprites"
:frame-rate="selectedAction.frameRate"
:is-modal-open="isModalOpen"
:temp-offset-index="tempOffsetData.index"
:temp-offset="tempOffsetData.offset"
@update:frame-rate="updateFrameRate"
@update:is-modal-open="isModalOpen = $event"
</div>
<div class="flex items-center mb-3">
<div class="mr-3 space-x-2">
<button class="btn-cyan px-4 py-1.5 min-w-24 text-left" type="button" @click.stop.prevent="openEditorModal(action)">
Editor
<div class="flex">
<small class="text-xs font-default">{{ action.action }}</small>
</div>
</button>
</div>
</div>
</div>
<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"
:temp-offset-index="getTempOffsetIndex(editorData.action)"
:temp-offset="getTempOffset(editorData.action)"
@update:frame-rate="(value) => updateFrameRate(editorData.action, value)"
@update:is-modal-open="(value) => handleEditorModalClose(editorData.action, value)"
@update:temp-offset="(index, offset) => handleTempOffsetChange(editorData.action, index, offset)"
/>
</div>
</div>
@ -69,25 +54,22 @@
<script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import type { Sprite, SpriteAction, UUID } 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 type { Sprite, SpriteAction } from '@/application/types'
import { downloadCache, uuidv4 } from '@/application/utilities'
import SpriteEditor from '@/components/gameMaster/assetManager/partials/sprite/partials/SpriteEditor.vue'
import { socketManager } from '@/managers/SocketManager'
import { SpriteStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore()
const selectedSprite = computed(() => assetManagerStore.selectedSprite)
const tempOffsetData = ref<Map<string, { index: number | undefined; offset: { x: number; y: number } | undefined }>>(new Map())
const spriteName = ref('')
const spriteActions = ref<SpriteAction[]>([])
const isModalOpen = ref(false)
const selectedAction = ref<SpriteAction | null>(null)
const openEditors = ref(new Map<string, { action: SpriteAction; isOpen: boolean }>())
if (!selectedSprite.value) {
console.error('No sprite selected')
@ -98,27 +80,31 @@ if (selectedSprite.value) {
spriteActions.value = sortSpriteActions(selectedSprite.value.spriteActions)
}
function deleteSprite() {
socketManager.emit(SocketEvent.GM_SPRITE_DELETE, { id: selectedSprite.value?.id }, (response: boolean) => {
async function deleteSprite() {
socketManager.emit(SocketEvent.GM_SPRITE_DELETE, { id: selectedSprite.value?.id }, async (response: boolean) => {
if (!response) {
console.error('Failed to delete sprite')
return
}
refreshSpriteList()
await downloadCache('sprites', new SpriteStorage())
await refreshSpriteList()
})
}
function copySprite() {
socketManager.emit(SocketEvent.GM_SPRITE_COPY, { id: selectedSprite.value?.id }, (response: boolean) => {
async function copySprite() {
socketManager.emit(SocketEvent.GM_SPRITE_COPY, { id: selectedSprite.value?.id }, async (response: boolean) => {
if (!response) {
console.error('Failed to copy sprite')
return
}
refreshSpriteList(false)
await downloadCache('sprites', new SpriteStorage())
await refreshSpriteList(false)
})
}
function refreshSpriteList(unsetSelectedSprite = true) {
async function refreshSpriteList(unsetSelectedSprite = true) {
socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response)
@ -128,7 +114,7 @@ function refreshSpriteList(unsetSelectedSprite = true) {
})
}
function saveSprite() {
async function saveSprite() {
if (!selectedSprite.value) {
console.error('No sprite selected')
return
@ -138,25 +124,27 @@ function saveSprite() {
id: selectedSprite.value.id,
name: spriteName.value,
spriteActions:
spriteActions.value?.map((action) => {
return {
action: action.action,
sprites: action.sprites,
originX: action.originX,
originY: action.originY,
frameRate: action.frameRate,
frameWidth: action.frameWidth,
frameHeight: action.frameHeight
}
}) ?? []
spriteActions.value?.map((action) => {
return {
action: action.action,
sprites: action.sprites,
originX: action.originX,
originY: action.originY,
frameRate: action.frameRate,
frameWidth: action.frameWidth,
frameHeight: action.frameHeight
}
}) ?? []
}
socketManager.emit(SocketEvent.GM_SPRITE_UPDATE, updatedSprite, (response: boolean) => {
socketManager.emit(SocketEvent.GM_SPRITE_UPDATE, updatedSprite, async (response: boolean) => {
if (!response) {
console.error('Failed to save sprite')
return
}
refreshSpriteList(false)
await downloadCache('sprites', new SpriteStorage())
await refreshSpriteList(false)
})
}
@ -187,39 +175,69 @@ function sortSpriteActions(actions: SpriteAction[]): SpriteAction[] {
return [...actions].sort((a, b) => a.action.localeCompare(b.action))
}
function openPreviewModal(action: SpriteAction) {
selectedAction.value = action
isModalOpen.value = true
function openEditorModal(action: SpriteAction) {
const newOpenEditors = new Map(openEditors.value)
newOpenEditors.set(action.id, { action, isOpen: true })
openEditors.value = newOpenEditors
}
function updateFrameRate(value: number) {
if (selectedAction.value) {
selectedAction.value.frameRate = value
}
function updateFrameRate(action: SpriteAction, value: number) {
console.log('update frame rate', action)
action.frameRate = value
}
const tempOffsetData = ref<{ index: number | undefined; offset: { x: number; y: number } | undefined }>({
index: undefined,
offset: undefined
})
function handleEditorModalClose(action: SpriteAction, isOpen: boolean) {
if (isOpen) return
const newOpenEditors = new Map(openEditors.value)
newOpenEditors.delete(action.id)
openEditors.value = newOpenEditors
}
function handleTempOffsetChange(action: SpriteAction, index: number, offset: { x: number; y: number }) {
if (selectedAction.value === action) {
tempOffsetData.value = { index, offset }
// Update the temporary offset data for this action
const newTempOffsetData = new Map(tempOffsetData.value)
newTempOffsetData.set(action.id, { index, offset })
tempOffsetData.value = newTempOffsetData
// Also update the actual sprite data so changes persist
if (action.sprites && action.sprites[index]) {
action.sprites[index].offset = { ...offset };
}
}
function getTempOffsetIndex(action: SpriteAction): number | undefined {
return tempOffsetData.value.get(action.id)?.index
}
function getTempOffset(action: SpriteAction): { x: number; y: number } | undefined {
return tempOffsetData.value.get(action.id)?.offset
}
watch(selectedSprite, (sprite: Sprite | null) => {
if (!sprite) return
spriteName.value = sprite.name
spriteActions.value = sortSpriteActions(sprite.spriteActions)
openEditors.value = new Map()
tempOffsetData.value = new Map() // Reset temp offset data when sprite changes
})
watch(isModalOpen, (newValue) => {
if (!newValue) {
selectedAction.value = null
interface SpriteImage {
url: string
offset: {
x: number
y: number
}
})
}
const imageDimensions = ref<{ [key: number]: { width: number; height: number } }>({})
const updateImageDimensions = (event: Event, index: number) => {
const img = event.target as HTMLImageElement
imageDimensions.value[index] = {
width: img.naturalWidth,
height: img.naturalHeight
}
}
onMounted(() => {
if (!selectedSprite.value) return
@ -228,4 +246,4 @@ onMounted(() => {
onBeforeUnmount(() => {
assetManagerStore.setSelectedSprite(null)
})
</script>
</script>

View File

@ -0,0 +1,366 @@
<template>
<Modal :is-modal-open="isModalOpen" :modal-width="700" :modal-height="330" :can-full-screen="true" bg-style="none" @modal:close="closeModal">
<template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">Sprite editor</h3>
</template>
<template #modalBody>
<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="relative flex py-5 items-center">
<div class="flex-grow border-t border-gray-400"></div>
<span class="flex-shrink mx-4 text-gray-400">Sprite action</span>
<div class="flex-grow border-solid border-gray-200"></div>
</div>
<div class="form-field-full">
<label for="action">Name</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="relative flex py-5 items-center">
<div class="flex-grow border-t border-gray-400"></div>
<span class="flex-shrink mx-4 text-gray-400">Sprite action image</span>
<div class="flex-grow border-t border-gray-400"></div>
</div>
<div class="form-field-half">
<label for="offset-x">Offset X</label>
<input class="input-field" type="number" step="1" v-model.number="offsetXModel" :disabled="isAnimating" />
</div>
<div class="form-field-half">
<label for="offset-y">Offset Y</label>
<input class="input-field" type="number" step="1" v-model.number="offsetYModel" :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) * (zoomLevel / 100)}px`,
bottom: `${(sprite.offset?.y || 0) * (zoomLevel / 100)}px`,
display: currentFrame === index ? 'block' : 'none',
transform: `scale(${zoomLevel / 100})`,
transformOrigin: 'bottom left',
userSelect: 'none',
pointerEvents: currentFrame === index ? 'auto' : 'none'
}"
@dragstart.prevent
/>
</div>
<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>
</template>
</Modal>
</template>
<script setup lang="ts">
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
tempOffsetIndex?: number
tempOffset?: { x: number; y: number }
}>()
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 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(() => {
if (props.frameRate <= 0) return 0
return (props.sprites.length / props.frameRate).toFixed(2)
})
const spritesWithTempOffset = computed(() => {
return props.sprites.map((sprite, index) => {
if (index === props.tempOffsetIndex && props.tempOffset) {
return { ...sprite, offset: props.tempOffset }
}
return sprite
})
})
const currentSprite = computed(() => {
if (currentFrame.value >= 0 && currentFrame.value < spritesWithTempOffset.value.length) {
return spritesWithTempOffset.value[currentFrame.value]
}
return null
})
// Create computed properties with getters and setters for two-way binding
const offsetXModel = computed({
get: () => currentSprite.value?.offset?.x || 0,
set: (value) => {
if (isAnimating.value) return
const newOffset = {
x: value,
y: currentSprite.value?.offset?.y || 0
}
emit('update:tempOffset', currentFrame.value, newOffset)
}
})
const offsetYModel = computed({
get: () => currentSprite.value?.offset?.y || 0,
set: (value) => {
if (isAnimating.value) return
const newOffset = {
x: currentSprite.value?.offset?.x || 0,
y: value
}
emit('update:tempOffset', currentFrame.value, newOffset)
}
})
// Toggle for showing reference sprites
const showReferenceSprites = ref(true)
function updateAnimation() {
stopAnimation()
if (props.frameRate <= 0 || props.sprites.length === 0) {
currentFrame.value = 0
return
}
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() {
if (animationInterval) {
clearInterval(animationInterval)
animationInterval = null
}
}
function selectFrame(index: number) {
currentFrame.value = index
stopAnimation()
isAnimating.value = false
}
function updateFrameRate() {
emit('update:frameRate', localFrameRate.value)
}
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() {
if (isDragging.value && currentSprite.value?.offset) {
// Ensure the final offset is applied when dragging stops
emit('update:tempOffset', currentFrame.value, {
x: currentSprite.value.offset.x,
y: currentSprite.value.offset.y
})
}
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) => {
localFrameRate.value = newValue
updateAnimation()
},
{ immediate: true }
)
watch(() => props.sprites, updateAnimation, { immediate: true })
watch(
() => isAnimating.value,
(newValue) => {
if (newValue) {
updateAnimation()
} else {
stopAnimation()
}
}
)
onMounted(() => {
isAnimating.value = props.frameRate > 0
if (isAnimating.value) {
updateAnimation()
}
})
onUnmounted(() => {
stopAnimation()
})
</script>

View File

@ -1,185 +0,0 @@
<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.url" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" @load="updateImageDimensions($event, index)" />
<div v-if="imageDimensions[index]" class="absolute bottom-1 right-1 bg-black/50 text-white text-xs px-1 py-0.5 rounded transition-opacity font-default">{{ imageDimensions[index].width }}x{{ imageDimensions[index].height }}</div>
<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 @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 ({{ selectedImageIndex }})</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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
</div>
<input type="file" ref="fileInput" @change="onFileChange" multiple accept="image/png" class="hidden" />
</template>
<script setup lang="ts">
import Modal from '@/components/utilities/Modal.vue'
import { ref, watch } from 'vue'
interface SpriteImage {
url: string
offset: {
x: number
y: number
}
}
interface Props {
modelValue: SpriteImage[]
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => []
})
const emit = defineEmits<{
(e: 'update:modelValue', value: SpriteImage[]): void
(e: 'close'): void
(e: 'tempOffsetChange', index: number, offset: { x: number; y: number }): 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()
}
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files) {
handleFiles(target.files)
}
}
const onDrop = (event: DragEvent) => {
if (event.dataTransfer?.files) {
handleFiles(event.dataTransfer.files)
}
}
const handleFiles = (files: FileList) => {
Array.from(files).forEach((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: SpriteImage[]) => {
emit('update:modelValue', newImages)
}
const deleteImage = (index: number) => {
const newImages = [...props.modelValue]
newImages.splice(index, 1)
updateImages(newImages)
}
const dragStart = (event: DragEvent, index: number) => {
draggedIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.dropEffect = 'move'
}
}
const drop = (event: DragEvent, dropIndex: number) => {
event.preventDefault()
if (draggedIndex.value !== null && draggedIndex.value !== dropIndex) {
const newImages = [...props.modelValue]
const [reorderedItem] = newImages.splice(draggedIndex.value, 1)
newImages.splice(dropIndex, 0, reorderedItem)
updateImages(newImages)
}
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()
}
const onOffsetChange = () => {
if (selectedImageIndex.value !== null) {
emit('tempOffsetChange', selectedImageIndex.value, tempOffset.value)
}
}
watch(tempOffset, onOffsetChange, { deep: true })
const imageDimensions = ref<{ [key: number]: { width: number; height: number } }>({})
const updateImageDimensions = (event: Event, index: number) => {
const img = event.target as HTMLImageElement
imageDimensions.value[index] = {
width: img.naturalWidth,
height: img.naturalHeight
}
}
</script>

View File

@ -1,151 +0,0 @@
<template>
<Modal :is-modal-open="isModalOpen" :modal-width="700" :modal-height="330" bg-style="none" @modal:close="closeModal">
<template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">View sprite</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'
}"
>
<img
v-for="(sprite, index) in spritesWithTempOffset"
: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',
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>
</div>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import type { SpriteImage } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
sprites: SpriteImage[]
frameRate: number
isModalOpen?: boolean
tempOffsetIndex?: number
tempOffset?: { x: number; y: number }
}>()
const emit = defineEmits<{
(e: 'update:frameRate', value: number): void
(e: 'update:isModalOpen', value: boolean): void
}>()
const currentFrame = ref(0)
const maxWidth = ref(250)
const maxHeight = ref(250)
const localFrameRate = ref(props.frameRate)
const zoomLevel = ref(100)
let animationInterval: number | null = null
const totalDuration = computed(() => {
if (props.frameRate <= 0) return 0
return (props.sprites.length / props.frameRate).toFixed(2)
})
const spritesWithTempOffset = computed(() => {
return props.sprites.map((sprite, index) => {
if (index === props.tempOffsetIndex && props.tempOffset) {
return { ...sprite, offset: props.tempOffset }
}
return sprite
})
})
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
}
}
function updateFrameRate() {
emit('update:frameRate', localFrameRate.value)
}
function closeModal() {
emit('update:isModalOpen', false)
}
watch(
() => props.frameRate,
(newValue) => {
localFrameRate.value = newValue
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>

View File

@ -26,16 +26,14 @@
import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import type { Tile } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import ChipsInput from '@/components/forms/ChipsInput.vue'
import { socketManager } from '@/managers/SocketManager'
import { TileStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore()
const tileStorage = new TileStorage()
const selectedTile = computed(() => assetManagerStore.selectedTile)
@ -63,12 +61,13 @@ async function deleteTile() {
console.error('Failed to delete tile')
return
}
await tileStorage.delete(selectedTile.value!.id)
refreshTileList()
await downloadCache('tiles', new TileStorage())
await refreshTileList()
})
}
function refreshTileList(unsetSelectedTile = true) {
async function refreshTileList(unsetSelectedTile = true) {
socketManager.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => {
assetManagerStore.setTileList(response)
@ -78,7 +77,7 @@ function refreshTileList(unsetSelectedTile = true) {
})
}
function saveTile() {
async function saveTile() {
if (!selectedTile.value) {
console.error('No tile selected')
return
@ -91,12 +90,14 @@ function saveTile() {
name: tileName.value,
tags: tileTags.value
},
(response: boolean) => {
async (response: boolean) => {
if (!response) {
console.error('Failed to save tile')
return
}
refreshTileList(false)
await downloadCache('tiles', new TileStorage())
await refreshTileList(false)
}
)
}

View File

@ -10,9 +10,9 @@ import MapEventTiles from '@/components/gameMaster/mapEditor/mapPartials/MapEven
import MapTiles from '@/components/gameMaster/mapEditor/mapPartials/MapTiles.vue'
import PlacedMapObjects from '@/components/gameMaster/mapEditor/mapPartials/PlacedMapObjects.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { cloneArray, createTileArray, createTileLayer, createTileMap, placeTiles } from '@/services/mapService'
import { cloneArray, createTileLayer, createTileMap, placeTiles } from '@/services/mapService'
import { TileStorage } from '@/storage/storages'
import { useManualRefHistory, useRefHistory } from '@vueuse/core'
import { useRefHistory } from '@vueuse/core'
import { useScene } from 'phavuer'
import { onBeforeUnmount, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
@ -58,6 +58,7 @@ watch(
mapTiles.value!.clearTiles()
eventTiles.value!.clearTiles()
mapEditor.currentMap.value.placedMapObjects = []
mapEditor.currentMap.value.mapEventTiles = []
updateAndCommit(mapEditor.currentMap.value)
mapEditor.resetClearTilesFlag()
}
@ -234,6 +235,10 @@ onUnmounted(() => {
mapEditor.reset()
})
setInterval(() => {
scene.children.queueDepthSort()
}, 0.2)
onBeforeUnmount(() => {
removeEventListener('keydown', handleKeyDown)
})

View File

@ -7,7 +7,7 @@
:placedMapObject="previewPlacedMapObject as PlacedMapObjectT"
/>
<SelectedPlacedMapObjectComponent v-if="mapEditor.selectedPlacedObject.value" :key="mapEditor.selectedPlacedObject.value.id" :map :placedMapObject="mapEditor.selectedPlacedObject.value" @move="moveMapObject" @rotate="rotatePlacedMapObject" @delete="deletePlacedMapObject" />
<PlacedMapObject v-for="placedMapObject in mapEditor.currentMap.value?.placedMapObjects" :tileMap :tileMapLayer :placedMapObject @pointerdown="clickPlacedMapObject(placedMapObject)" />
<PlacedMapObject v-for="placedMapObject in mapEditor.currentMap.value?.placedMapObjects" :tileMap :tileMapLayer :placedMapObject @pointerdown="clickPlacedMapObject(placedMapObject)" :key="`${placedMapObject.id}-${placedMapObjectKey}`" />
</template>
<script setup lang="ts">
@ -26,6 +26,7 @@ import TilemapLayer = Phaser.Tilemaps.TilemapLayer
const scene = useScene()
const mapEditor = useMapEditorComposable()
const map = computed(() => mapEditor.currentMap.value!)
const placedMapObjectKey = computed(() => mapEditor.refreshMapObject.value)
const emit = defineEmits<{ (e: 'update', map: MapT): void; (e: 'updateAndCommit', map: MapT): void; (e: 'pauseObjectTracking'): void; (e: 'resumeObjectTracking'): void }>()

View File

@ -1,17 +1,15 @@
<template>
<Modal ref="modalRef" :is-resizable="false" :modal-width="300" :modal-height="360" bg-style="none">
<template #modalHeader>
<h3 class="text-lg text-white">Maps</h3>
<div class="flex items-center">
<button class="btn-cyan w-7 h-7 font-normal flex items-center justify-center" @click="createMapModal?.open">+</button>
<h3 class="text-lg text-white ml-2">Maps</h3>
</div>
</template>
<template #modalBody>
<div class="my-4 mx-auto h-full">
<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="createMapModal?.open">New</button>
</div>
<div class="overflow-y-auto h-[calc(100%-20px)]">
<div class="mx-auto h-full">
<div class="overflow-y-auto h-[calc(100%)]">
<div class="relative p-2.5 cursor-pointer flex gap-y-2.5 gap-x-5 flex-wrap" v-for="(map, index) in 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">
@ -24,7 +22,6 @@
</div>
</template>
</Modal>
<CreateMap ref="createMapModal" @create="fetchMaps" />
</template>

View File

@ -10,8 +10,9 @@
<div class="filler"></div>
<div class="w-2/3 max-w-[860px]" v-if="!isLoading">
<div class="mb-5 flex flex-col gap-1">
<h1 class="text-white font-bold">SELECT CHARACTER TO PLAY</h1>
<p class="m-0">Maximum of 4 characters can be created per player</p>
<h1 class="text-white font-bold">{{ isCreatingCharacter ? 'CREATE CHARACTER' : 'SELECT CHARACTER TO PLAY' }}</h1>
<p class="m-0" v-if="!isCreatingCharacter">Maximum of 4 characters can be created per player</p>
<p class="m-0" v-if="isCreatingCharacter">Customize your new character</p>
</div>
<div class="flex w-full max-lg:flex-col lg:h-[400px] default-border rounded-md bg-gray">
<div class="lg:min-w-[285px] max-lg:min-h-[383px] lg:w-1/3 h-full bg-[url('/assets/ui-texture.png')] bg-no-repeat bg-cover bg-center border-0 max-lg:border-b lg:border-r border-solid border-gray-500 max-lg:rounded-t-md lg:rounded-l-md relative">
@ -20,68 +21,95 @@
<img src="/assets/placeholders/head.png" class="w-9 h-9 object-contain center-element" alt="Player head" />
<input class="h-full w-full absolute m-0 z-10 hover:cursor-pointer focus-visible:outline-offset-0 btn-sound" type="radio" name="character" :value="character.id" v-model="selectedCharacterId" />
</div>
<div class="character relative rounded default-border w-12 h-12 bg-[url('/assets/ui-texture.png')]" :class="{ active: characters.length == 0 }" v-if="characters.length < 4">
<button class="p-0 h-full w-full flex flex-col justify-between focus-visible:outline-offset-0 btn-sound" @click="isCreateNewCharacterModalOpen = true">
<img class="w-6 h-6 object-contain center-element btn-sound" draggable="false" src="/assets/icons/plus-icon.svg" />
<div class="character relative rounded default-border w-12 h-12 bg-[url('/assets/ui-texture.png')]" :class="{ active: isCreatingCharacter }" v-if="characters.length < characterCreationSettings.maxCharacters">
<button class="p-0 h-full w-full flex flex-col justify-between focus-visible:outline-offset-0 btn-sound" @click="startCharacterCreation">
<img class="w-6 h-6 object-contain center-element btn-sound" draggable="false" src="/assets/icons/plus-icon.svg" alt="Add character" />
</button>
</div>
</div>
<div class="py-6 px-8 h-[calc(100%_-_48px)] flex flex-col items-center gap-6 justify-center" v-if="selectedCharacterId">
<input class="input-field w-[158px]" type="text" name="name" :placeholder="characters.find((c) => c.id == selectedCharacterId)?.name" />
<div class="flex flex-col gap-4 items-center">
<div class="flex flex-col gap-3">
<div class="bg-[url('/assets/ui-elements/character-select-ui-shape.svg')] w-[190px] h-52 bg-no-repeat bg-center flex items-center justify-between">
<div class="py-6 px-8 h-[calc(100%_-_48px)] flex flex-col items-center gap-6 justify-center">
<template v-if="selectedCharacterId && !isCreatingCharacter">
<input class="input-field w-[158px]" type="text" name="name" :placeholder="characters.find((c) => c.id == selectedCharacterId)?.name" v-model="newNickname" />
<div class="flex flex-col gap-4 items-center">
<div class="flex flex-col gap-3">
<div class="bg-[url('/assets/ui-elements/character-select-ui-shape.svg')] w-[190px] h-52 bg-no-repeat bg-center flex items-center justify-between">
<button class="ml-6 w-4 h-8 p-0">
<img src="/assets/icons/triangle-icon.svg" class="w-3 h-3.5 m-auto" alt="Arrow left" />
</button>
<img class="w-24 object-contain mb-3.5 max-h-[70%]" alt="Player avatar" :src="config.server_endpoint + '/avatar/s/' + characters.find((c) => c.id === selectedCharacterId)?.characterType + '/' + (selectedHairId ?? 'default')" />
<button class="mr-6 w-4 h-8 p-0">
<img src="/assets/icons/triangle-icon.svg" class="w-3 h-3.5 -scale-x-100" alt="Arrow right" />
</button>
</div>
</div>
</div>
</template>
<template v-if="isCreatingCharacter">
<div class="flex flex-col gap-4 items-center">
<input class="input-field" v-model="newCharacterName" name="name" id="name" placeholder="Enter a nickname..." />
<div class="bg-[url('/assets/ui-elements/character-select-ui-shape.svg')] w-[190px] h-52 bg-no-repeat bg-center flex items-center justify-center">
<button class="ml-6 w-4 h-8 p-0">
<img src="/assets/icons/triangle-icon.svg" class="w-3 h-3.5 m-auto" alt="Arrow left" />
</button>
<img class="w-24 object-contain mb-3.5 max-h-[70%]" alt="Player avatar" :src="config.server_endpoint + '/avatar/s/' + characters.find((c) => c.id === selectedCharacterId)?.characterType + '/' + (selectedHairId ?? 'default')" />
<img class="w-24 object-contain mb-3.5 max-h-[70%]" alt="Player avatar" :src="config.server_endpoint + '/avatar/s/' + defaultCharacterTypeId + '/' + (selectedHairId ?? 'default')" />
<button class="mr-6 w-4 h-8 p-0">
<img src="/assets/icons/triangle-icon.svg" class="w-3 h-3.5 -scale-x-100" alt="Arrow right" />
</button>
</div>
<div class="flex justify-between w-[190px]">
<button class="btn-empty flex gap-2" :class="{ selected: selectedGender === 'MALE' }" @click="selectedGender = 'MALE'">
<img src="/assets/icons/male-icon.svg" class="w-4 h-4 m-auto" alt="Male symbol" />
<span class="text-white">Male</span>
</button>
<button class="btn-empty flex gap-2" :class="{ selected: selectedGender === 'FEMALE' }" @click="selectedGender = 'FEMALE'">
<img src="/assets/icons/male-icon.svg" class="w-4 h-4 m-auto" alt="Female symbol" />
<span class="text-white">Female</span>
</button>
</div>
</div>
<!-- TODO: update gender on (selected) character -->
<!-- <div class="flex justify-between w-[190px]">-->
<!-- <button class="btn-empty flex gap-2" :class="{ selected: characters.find((c) => c.id == selectedCharacterId)?.characterType?.gender === 'MALE' }">-->
<!-- <img src="/assets/icons/male-icon.svg" class="w-4 h-4 m-auto" alt="Male symbol" />-->
<!-- <span class="text-white">Male</span>-->
<!-- </button>-->
<!-- <button class="btn-empty flex gap-2" :class="{ selected: characters.find((c) => c.id == selectedCharacterId)?.characterType?.gender === 'FEMALE' }">-->
<!-- <img src="/assets/icons/male-icon.svg" class="w-4 h-4 m-auto" alt="Male symbol" />-->
<!-- <span class="text-white">Female</span>-->
<!-- </button>-->
<!-- </div>-->
</div>
</template>
</div>
</div>
<div class="flex-1 lg:w-2/3 max-lg:min-h-[212px] h-full bg-[url('/assets/ui-texture.png')] bg-no-repeat bg-cover bg-center max-lg:rounded-bl-md rounded-r-md">
<div class="py-6 px-8 h-[calc(100%_-_48px)] flex flex-col items-center gap-10" v-if="selectedCharacterId">
<div class="py-6 px-8 h-[calc(100%_-_48px)] flex flex-col items-center gap-10" v-if="selectedCharacterId || isCreatingCharacter">
<div class="flex flex-col gap-3 w-full">
<span class="text-sm">Hair color</span>
<div class="flex gap-2 flex-wrap">
<div
class="hair-deselect relative flex justify-center items-center bg-gray default-border w-[18px] h-[18px] p-2 rounded-sm hover:bg-gray-500 hover:border-gray-400 focus-visible:outline-none focus-visible:border-white focus-visible:bg-cyan has-[:checked]:bg-cyan has-[:checked]:border-transparent"
>
<img src="/assets/icons/x-button-gray.svg" class="w-4 h-4" alt="Empty button" />
<input type="radio" name="hair-color" :value="null" v-model="selectedHairColor" class="h-full w-full absolute left-0 top-0 m-0 z-10 hover:cursor-pointer focus-visible:outline-offset-0 focus-visible:outline-white" />
</div>
<div
v-for="color in uniqueHairColors"
class="relative flex justify-center items-center default-border w-[18px] h-[18px] p-2 rounded-sm hover:bg-gray-500 hover:border-gray-400 focus-visible:outline-none focus-visible:border-gray-300 focus-visible:bg-gray-500 has-[:checked]:bg-cyan has-[:checked]:border-transparent"
>
<div class="w-full h-full rounded-sm" :style="getHairColorStyle(color)"></div>
<input type="radio" name="hair-color" :value="color" v-model="selectedHairColor" class="h-full w-full absolute left-0 top-0 m-0 z-10 hover:cursor-pointer focus-visible:outline-offset-0 focus-visible:outline-white" />
</div>
</div>
</div>
<div class="flex flex-col gap-3 w-full">
<span class="text-sm">Hairstyle</span>
<div class="flex gap-2 flex-wrap max-h-20 overflow-y-auto scrollbar">
<div class="flex gap-2 flex-wrap">
<div
class="hair-deselect relative flex justify-center items-center bg-gray default-border w-[18px] h-[18px] p-2 rounded-sm hover:bg-gray-500 hover:border-gray-400 focus-visible:outline-none focus-visible:border-white focus-visible:bg-cyan has-[:checked]:bg-cyan has-[:checked]:border-transparent"
>
<img src="/assets/icons/x-button-gray.svg" class="w-4 h-4" alt="Empty button" />
<input type="radio" name="hair" :value="null" v-model="selectedHairId" class="h-full w-full absolute left-0 top-0 m-0 z-10 hover:cursor-pointer focus-visible:outline-offset-0 focus-visible:outline-white" />
</div>
<!-- TODO #255: make radio button so we can set a value, do the same with swatches -->
<div
v-for="hair in characterHairs"
class="relative flex justify-center items-center bg-gray default-border w-[18px] h-[18px] p-2 rounded-sm hover:bg-gray-500 hover:border-gray-400 focus-visible:outline-none focus-visible:border-gray-300 focus-visible:bg-gray-500 has-[:checked]:bg-cyan has-[:checked]:border-transparent"
v-for="hair in filteredHairs"
class="relative flex justify-center items-center bg-gray default-border w-[18px] h-[18px] p-2 rounded-sm hover:bg-gray-500 hover:border-gray-400 focus-visible:outline-none focus-visible:border-gray-300 focus-visible:bg-gray-500 has-[:checked]:bg-cyan has-[:checked]:border-transparent overflow-hidden"
>
<img class="h-4 object-contain" :src="config.server_endpoint + '/textures/sprites/' + hair.sprite + '/front.png'" alt="Hair sprite" />
<div class="absolute inset-0 flex items-center justify-center">
<img class="h-16 object-contain scale-[1] mt-8 origin-center" :src="config.server_endpoint + '/textures/sprites/' + hair.sprite + '/front.png'" alt="Hair sprite" />
</div>
<input type="radio" name="hair" :value="hair.id" v-model="selectedHairId" class="h-full w-full absolute left-0 top-0 m-0 z-10 hover:cursor-pointer focus-visible:outline-offset-0 focus-visible:outline-white" />
</div>
</div>
</div>
<div class="flex flex-col gap-3 w-full">
<span class="text-sm">Hair color</span>
<div class="flex gap-2 flex-wrap">
<!-- TODO: replace with hair colors -->
<input type="radio" name="hair-color" v-for="n in 10" class="bg-red w-6 h-6 m-0 rounded-sm hover:cursor-pointer checked:outline checked:outline-1 checked:outline-white" />
</div>
</div>
</div>
</div>
</div>
@ -89,57 +117,82 @@
<div v-else>
<img class="w-20 invert-80" src="/assets/icons/loading-icon1.svg" alt="Loading" />
</div>
<div class="w-2/3 button-wrapper flex self-center justify-center lg:justify-end gap-4 max-w-[860px]" v-if="!isLoading">
<button class="btn-empty min-w-48" @click.stop="gameStore.disconnectSocket()">Back</button>
<button class="btn-cyan min-w-48 disabled:bg-cyan-800 disabled:cursor-not-allowed" :disabled="!selectedCharacterId" @click="loginWithCharacter()">Play now</button>
<template v-if="!isCreatingCharacter">
<button class="btn-empty min-w-48" @click.stop="gameStore.disconnectSocket()">Back</button>
<button class="btn-cyan min-w-48 disabled:bg-cyan-800 disabled:cursor-not-allowed" :disabled="!selectedCharacterId" @click="loginWithCharacter()">Play now</button>
</template>
<template v-else>
<button class="btn-empty min-w-48" @click="cancelCharacterCreation">Cancel</button>
<button class="btn-cyan min-w-48" @click="createCharacter">Create</button>
</template>
</div>
</div>
</div>
<!-- CREATE CHARACTER MODAL -->
<Modal :isModalOpen="isCreateNewCharacterModalOpen" @modal:close="isCreateNewCharacterModalOpen = false" :modal-width="430" :modal-height="275">
<template #modalHeader>
<h3 class="m-0 font-medium text-white">Create your character</h3>
</template>
<template #modalBody>
<div class="p-4 h-[calc(100%_-_32px)]">
<form method="post" @submit.prevent="createCharacter" class="h-full flex flex-col justify-between">
<div class="form-field-full">
<label for="name" class="text-white">Nickname</label>
<input class="input-field" v-model="newCharacterName" name="name" id="name" placeholder="Enter a nickname..." />
</div>
<div class="grid grid-flow-col justify-stretch gap-4">
<button type="button" class="btn-empty py-1.5 px-4 inline-block" @click.prevent="isCreateNewCharacterModalOpen = false">Cancel</button>
<button class="btn-cyan py-1.5 px-4 inline-block" type="submit">Create</button>
</div>
</form>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import { type CharacterHair, type Character as CharacterT, type Map } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue'
import { useSoundComposable } from '@/composables/useSoundComposable'
import { socketManager } from '@/managers/SocketManager'
import { CharacterHairStorage } from '@/storage/storages'
import { CharacterHairStorage, CharacterTypeStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const characterCreationSettings = {
maxCharacters: 4,
minNameLength: 3,
maxNameLength: 20,
defaultGender: 'MALE' as const
}
const { playSound } = useSoundComposable()
const gameStore = useGameStore()
const isLoading = ref<boolean>(true)
const characters = ref<CharacterT[]>([])
const selectedCharacterId = ref<string | null>(null)
const isCreateNewCharacterModalOpen = ref<boolean>(false)
const newNickname = ref<string>('')
const newCharacterName = ref<string>('')
const characterHairs = ref<CharacterHair[]>([])
const selectedHairId = ref<string | null>(null)
const defaultCharacterTypeId = ref<string>('')
const isCreatingCharacter = ref<boolean>(false)
const selectedGender = ref()
const selectedHairColor = ref<string | null>(null)
const uniqueHairColors = computed(() => {
return [...new Set(characterHairs.value.map((hair) => hair.color))]
})
const filteredHairs = computed(() => {
if (!selectedHairColor.value) return characterHairs.value
return characterHairs.value.filter((hair) => hair.color === selectedHairColor.value)
})
function getHairColorStyle(color: string | null) {
return {
backgroundColor: color,
border: selectedHairColor.value === color ? '1px solid white' : '1px solid rgba(255, 255, 255, 0.2)'
}
}
function startCharacterCreation() {
isCreatingCharacter.value = true
selectedCharacterId.value = null
newCharacterName.value = ''
selectedHairId.value = null
selectedHairColor.value = null
selectedGender.value = characterCreationSettings.defaultGender
}
function cancelCharacterCreation() {
isCreatingCharacter.value = false
newCharacterName.value = ''
selectedHairId.value = null
selectedHairColor.value = null
}
// Fetch characters
setTimeout(() => {
@ -151,7 +204,6 @@ socketManager.on(SocketEvent.CHARACTER_LIST, (data: any) => {
isLoading.value = false
})
// Select character logics
function loginWithCharacter() {
if (!selectedCharacterId.value) return
@ -159,7 +211,8 @@ function loginWithCharacter() {
SocketEvent.CHARACTER_CONNECT,
{
characterId: selectedCharacterId.value,
characterHairId: selectedHairId.value
characterHairId: selectedHairId.value,
newNickname: newNickname.value
},
(response: { character: CharacterT; map: Map; characters: CharacterT[] }) => {
gameStore.setCharacter(response.character)
@ -169,22 +222,46 @@ function loginWithCharacter() {
// Create character logics
function createCharacter() {
socketManager.emit(SocketEvent.CHARACTER_CREATE, { name: newCharacterName.value }, (success: boolean) => {
if (success) return
isCreateNewCharacterModalOpen.value = false
})
if (newCharacterName.value.length < characterCreationSettings.minNameLength || newCharacterName.value.length > characterCreationSettings.maxNameLength) {
return
}
socketManager.emit(
SocketEvent.CHARACTER_CREATE,
{
name: newCharacterName.value,
gender: selectedGender.value,
hairId: selectedHairId.value
},
(success: boolean) => {
if (success) {
cancelCharacterCreation()
socketManager.emit(SocketEvent.CHARACTER_LIST)
}
}
)
}
// Watch changes for selected character and update hairs
watch(selectedCharacterId, (characterId) => {
if (!characterId) return
newNickname.value = ''
isCreatingCharacter.value = false
selectedHairId.value = characters.value.find((c) => c.id == characterId)?.characterHair ?? null
})
onMounted(async () => {
playSound('/assets/music/intro.mp3')
await playSound('/assets/music/intro.mp3')
const characterHairStorage = new CharacterHairStorage()
const characterTypeStorage = new CharacterTypeStorage()
characterHairs.value = await characterHairStorage.getAll()
// Get the first available character type for preview
const types = await characterTypeStorage.getAll()
const defaultType = types.find((type) => type.isSelectable)
if (defaultType) {
defaultCharacterTypeId.value = defaultType.id
}
})
onBeforeUnmount(() => {

View File

@ -23,6 +23,7 @@ import { SocketEvent } from '@/application/enums'
import { socketManager } from '@/managers/SocketManager'
import 'phaser'
import type { Map as MapT } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import Map from '@/components/gameMaster/mapEditor/Map.vue'
import MapList from '@/components/gameMaster/mapEditor/partials/MapList.vue'
import MapObjectList from '@/components/gameMaster/mapEditor/partials/MapObjectList.vue'
@ -33,13 +34,10 @@ import Toolbar from '@/components/gameMaster/mapEditor/partials/Toolbar.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { loadAllTileTextures } from '@/services/mapService'
import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { Game, Scene } from 'phavuer'
import { ref, useTemplateRef } from 'vue'
import { ref, toRaw, useTemplateRef } from 'vue'
const mapStorage = new MapStorage()
const mapEditor = useMapEditorComposable()
const gameStore = useGameStore()
const mapModal = useTemplateRef('mapModal')
const mapSettingsModal = useTemplateRef('mapSettingsModal')
@ -84,8 +82,8 @@ const preloadScene = async (scene: Phaser.Scene) => {
})
}
function save() {
const currentMap = mapEditor.currentMap.value
async function save() {
const currentMap = toRaw(mapEditor.currentMap.value)
if (!currentMap) return
const data = {
@ -93,8 +91,9 @@ function save() {
mapId: currentMap.id
}
socketManager.emit(SocketEvent.GM_MAP_UPDATE, data, (response: MapT) => {
mapStorage.update(response.id, response)
socketManager.emit(SocketEvent.GM_MAP_UPDATE, data, async (response: MapT) => {
if (!response.id) return
await downloadCache('maps', new MapStorage())
})
}

View File

@ -1,22 +0,0 @@
<template>
<div class="mb-4 flex flex-col gap-3">
<div @click="toggle" class="p-3 bg-gray-300 bg-opacity-50 rounded hover:bg-gray-400 text-white font-default cursor-pointer">
<slot name="header" />
</div>
<transition enter-active-class="transition-all duration-300 ease-in-out" leave-active-class="transition-all duration-300 ease-in-out" enter-from-class="opacity-0 max-h-0" enter-to-class="opacity-100 max-h-96" leave-from-class="opacity-100 max-h-96" leave-to-class="opacity-0 max-h-0">
<div v-if="isOpen" class="overflow-hidden">
<slot name="content" />
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const isOpen = ref(false)
const toggle = () => {
isOpen.value = !isOpen.value
}
</script>

View File

@ -14,7 +14,7 @@ import { SocketEvent } from '@/application/enums'
import Modal from '@/components/utilities/Modal.vue'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore'
import { onBeforeMount, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { onMounted, onUnmounted, watch } from 'vue'
const gameStore = useGameStore()
@ -37,13 +37,13 @@ function setupNotificationListener(connection: any) {
}
onMounted(() => {
const connection = gameStore.connection
const connection = socketManager.connection
if (connection) {
setupNotificationListener(connection)
} else {
// Watch for changes in the socket connection
watch(
() => gameStore.connection,
() => socketManager.connection,
(newConnection) => {
if (newConnection) setupNotificationListener(newConnection)
}
@ -52,7 +52,7 @@ onMounted(() => {
})
onUnmounted(() => {
const connection = gameStore.connection
const connection = socketManager.connection
if (connection) {
connection.off(SocketEvent.NOTIFICATION)
}

View File

@ -1,9 +1,9 @@
import config from '@/application/config'
import { Direction } from '@/application/enums'
import { type MapCharacter } from '@/application/types'
import { type MapCharacter, type SpriteAction } from '@/application/types'
import { calculateIsometricDepth, tileToWorldX, tileToWorldY } from '@/services/mapService'
import { loadSpriteTextures } from '@/services/textureService'
import { CharacterTypeStorage } from '@/storage/storages'
import { CharacterTypeStorage, SpriteStorage } from '@/storage/storages'
import { refObj } from 'phavuer'
import { computed, ref } from 'vue'
@ -72,7 +72,7 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
// Add new listener
characterSprite.value.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
characterSprite.value!.setFrame(0)
characterSprite.value!.setTexture(charTexture.value)
characterSprite.value!.setTexture(spriteSpriteActionId.value)
})
characterSprite.value.anims.play(
@ -96,11 +96,23 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
return [0, 6].includes(mapCharacter.character.rotation ?? 0) ? 'left_up' : 'right_down'
})
const getSpriteHeightByAction = async (action: string = '') => {
if (!characterSpriteId.value) return 0
const spriteStorage = new SpriteStorage()
const sprite = await spriteStorage.getById(characterSpriteId.value)
let actionWithDirection = `${currentAction.value}_${currentDirection.value}`
if (action) actionWithDirection = action
return sprite?.spriteActions?.find((spriteAction: SpriteAction) => spriteAction.action === actionWithDirection)?.frameHeight ?? 0
}
const spriteHeight = computed(() => getSpriteHeightByAction(currentAction.value))
const currentAction = computed(() => {
return mapCharacter.isMoving ? 'walk' : 'idle'
})
const charTexture = computed(() => {
const spriteSpriteActionId = computed(() => {
const spriteId = characterSpriteId.value ?? 'idle_right_down'
return `${spriteId}-${currentAction.value}_${currentDirection.value}`
})
@ -109,11 +121,11 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
if (!characterSprite.value) return
if (mapCharacter.isMoving) {
characterSprite.value.anims.play(charTexture.value, true)
characterSprite.value.anims.play(spriteSpriteActionId.value, true)
} else {
characterSprite.value.anims.stop()
characterSprite.value.setFrame(0)
characterSprite.value.setTexture(charTexture.value)
characterSprite.value.setTexture(spriteSpriteActionId.value)
}
}
@ -130,7 +142,8 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
}
if (characterSprite.value) {
characterSprite.value.setTexture(charTexture.value)
characterSprite!.value?.setOrigin(0.5, 1)
characterSprite.value.setTexture(spriteSpriteActionId.value)
characterSprite.value.setFlipX(isFlippedX.value)
}
@ -146,10 +159,15 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
characterContainer,
characterSpriteId,
characterSprite,
spriteHeight,
currentAction,
spriteSpriteActionId,
currentPositionX,
currentPositionY,
currentDirection,
isometricDepth,
isFlippedX,
getSpriteHeightByAction,
updatePosition,
playAnimation,
calcDirection,

View File

@ -62,8 +62,8 @@ export function createTileArray(width: number, height: number, tile: string = 'b
return Array.from({ length: height }, () => Array.from({ length: width }, () => tile))
}
export const calculateIsometricDepth = (positionX: number, positionY: number, pivotPoints: { x: number; y: number }[] = []) => {
return Math.max(positionX + positionY)
export const calculateIsometricDepth = (positionX: number, positionY: number) => {
return positionX + positionY
}
async function loadTileTextures(tiles: TileT[], scene: Phaser.Scene) {