1
0
forked from noxious/client

Compare commits

...

74 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
4042808d4e Socket event enum enhancement 2025-02-17 01:20:16 +01:00
a6d6d894a9 #363 : Moved socket logic into socketManager and removed it from Pinia store 2025-02-17 01:17:02 +01:00
0c61fe77de Paint tool fixes 2025-02-16 21:36:31 +01:00
bfb2bcb939 Map editor teleport enhancements 2025-02-16 21:18:06 +01:00
af5a97f66d Removed debug console log 2025-02-16 19:04:35 +01:00
79fa54b1bb Cache improvement 2025-02-16 19:03:58 +01:00
dbb4cae154 Only update value if is self 2025-02-16 18:21:53 +01:00
9a8220e4e0 Teleport walk fix 2025-02-16 18:16:35 +01:00
bc0db8b32b Receive new location as array instead of object 2025-02-16 17:29:21 +01:00
ad611ef593 Send new location as array instead of object 2025-02-16 17:19:19 +01:00
d819a84a37 npm update 2025-02-16 17:15:28 +01:00
15dc331a43 Improved movement logic 2025-02-16 17:14:24 +01:00
920baaebde Updated interval value 2025-02-16 01:55:04 +01:00
b569888682 Set selectedPlacedObject null when exiting map editor 2025-02-15 20:46:00 +01:00
94eab073e6 Max 2. pivot points 2025-02-15 17:57:30 +01:00
d843b954ab TS 2025-02-15 17:42:01 +01:00
337446497b Simple 2025-02-15 16:51:58 +01:00
d8805dd775 Added pivot point logic 2025-02-15 16:40:17 +01:00
4c040c21d6 npm update 2025-02-15 15:37:05 +01:00
d0af83ec60 N/A 2025-02-14 23:15:35 +01:00
2de34d2034 Package updates 2025-02-14 22:56:33 +01:00
132121c082 ToggleGmPanel > set false 2025-02-14 16:22:21 +01:00
201f628bfa Saving teleports works again 2025-02-14 03:16:22 +01:00
af99d66595 Add if check to quick login 2025-02-14 03:06:49 +01:00
56f30093f6 Teleport fix WIP 2025-02-14 03:04:42 +01:00
8f26a40a0e 11 for fast login 2025-02-14 03:03:57 +01:00
110fd4e608 TS improvements 2025-02-14 02:49:14 +01:00
c1edf31ca0 TS fix 2025-02-14 02:44:14 +01:00
90c0ed3141 Open teleport modal fix 2025-02-14 02:42:01 +01:00
bcf0d2832d Feedback Shilo
the placement of map objects sometimes placed in a tile that the mouse wasnt over. but i didnt try reproducing the issue.
opening map editor doesnt close the gm window (nor show the map editor). not obvious.
side panels like map objects doesnt have a close button. so i was only able to close it by switching to "move" tool.
2025-02-14 02:34:56 +01:00
79 changed files with 7245 additions and 1152 deletions

902
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,19 +16,22 @@
"dependencies": { "dependencies": {
"@vueuse/core": "^10.5.0", "@vueuse/core": "^10.5.0",
"@vueuse/integrations": "^10.5.0", "@vueuse/integrations": "^10.5.0",
"axios": "^1.7.7", "axios": "^1.7.9",
"dexie": "^4.0.8", "dexie": "^4.0.11",
"phaser": "3.87.0", "phaser": "^3.88.2",
"pinia": "^2.1.6", "phaser3-rex-plugins": "^1.80.13",
"phavuer": "^0.16.5",
"pinia": "^2.3.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.1",
"universal-cookie": "^6.1.3", "universal-cookie": "^6.1.3",
"vite-plugin-image-optimizer": "^1.1.8", "vite-plugin-image-optimizer": "^1.1.8",
"vue": "^3.5.12", "vue": "^3.5.13",
"zod": "^3.22.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.0", "@ianvs/prettier-plugin-sort-imports": "^4.4.0",
"@tauri-apps/cli": "^2.2.7",
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/node": "^20.14.11", "@types/node": "^20.14.11",
@ -38,8 +41,6 @@
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"jsdom": "^24.1.1", "jsdom": "^24.1.1",
"npm-run-all2": "^6.2.3", "npm-run-all2": "^6.2.3",
"phaser3-rex-plugins": "^1.80.8",
"phavuer": "^0.16.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"sass": "^1.79.4", "sass": "^1.79.4",

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

@ -16,6 +16,7 @@ import Debug from '@/components/utilities/Debug.vue'
import Notifications from '@/components/utilities/Notifications.vue' import Notifications from '@/components/utilities/Notifications.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { useSoundComposable } from '@/composables/useSoundComposable' import { useSoundComposable } from '@/composables/useSoundComposable'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
@ -26,8 +27,8 @@ const { playSound } = useSoundComposable()
const currentScreen = computed(() => { const currentScreen = computed(() => {
if (!gameStore.game.isLoaded) return Loading if (!gameStore.game.isLoaded) return Loading
if (!gameStore.connection) return Login if (!socketManager.connection) return Login
if (!gameStore.token) return Login if (!socketManager.token) return Login
if (!gameStore.character) return Characters if (!gameStore.character) return Characters
if (mapEditor.active.value) return MapEditor if (mapEditor.active.value) return MapEditor
return Game return Game

View File

@ -5,6 +5,8 @@ export enum Direction {
} }
export enum SocketEvent { export enum SocketEvent {
CONNECT_ERROR = 'connect_error',
RECONNECT_FAILED = 'reconnect_failed',
CLOSE = '52', CLOSE = '52',
DATA = '51', DATA = '51',
CHARACTER_CONNECT = '50', CHARACTER_CONNECT = '50',
@ -41,7 +43,7 @@ export enum SocketEvent {
GM_MAP_REQUEST = '19', GM_MAP_REQUEST = '19',
GM_MAP_UPDATE = '18', GM_MAP_UPDATE = '18',
MAP_CHARACTER_MOVEERROR = '17', MAP_CHARACTER_MOVEERROR = '17',
DISCONNECT = '16', DISCONNECT = 'disconnect',
USER_DISCONNECT = '15', USER_DISCONNECT = '15',
LOGIN = '14', LOGIN = '14',
LOGGED_IN = '13', LOGGED_IN = '13',
@ -49,7 +51,7 @@ export enum SocketEvent {
DATE = '11', DATE = '11',
FAILED = '10', FAILED = '10',
COMPLETED = '9', COMPLETED = '9',
CONNECTION = '8', CONNECTION = 'connection',
WEATHER = '7', WEATHER = '7',
CHARACTER_DISCONNECT = '6', CHARACTER_DISCONNECT = '6',
MAP_CHARACTER_ATTACK = '5', MAP_CHARACTER_ATTACK = '5',

View File

@ -36,7 +36,8 @@ export type Tile = {
export type MapObject = { export type MapObject = {
id: string id: string
name: string name: string
tags: any | null tags: string[]
depthOffsets: number[]
originX: number originX: number
originY: number originY: number
frameRate: number frameRate: number
@ -100,7 +101,7 @@ export enum MapEventTileType {
export type MapEventTile = { export type MapEventTile = {
id: string id: string
mapid: string map: string
type: MapEventTileType type: MapEventTileType
positionX: number positionX: number
positionY: number positionY: number
@ -150,8 +151,9 @@ export type CharacterType = {
export type CharacterHair = { export type CharacterHair = {
id: string id: string
name: string name: string
sprite?: Sprite sprite: string | Sprite
gender: CharacterGender gender: CharacterGender
color: string
isSelectable: boolean isSelectable: boolean
} }
@ -208,6 +210,8 @@ export enum CharacterEquipmentSlotType {
export type Sprite = { export type Sprite = {
id: string id: string
name: string name: string
width: number | null
height: number | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
spriteActions: SpriteAction[] spriteActions: SpriteAction[]

View File

@ -21,7 +21,17 @@ export async function downloadCache<T extends { id: string; updatedAt: Date }>(e
} }
const items = response.data ?? [] const items = response.data ?? []
const serverItemIds = new Set(items.map((item) => item.id))
// Remove items that don't exist on server
const existingItems = await storage.getAll()
for (const existingItem of existingItems) {
if (!serverItemIds.has(existingItem.id)) {
await storage.delete(existingItem.id)
}
}
// Update or add new items
for (const item of items) { for (const item of items) {
let overwrite = false let overwrite = false
const existingItem = await storage.getById(item.id) const existingItem = await storage.getById(item.id)

View File

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

View File

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

View File

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

View File

@ -22,6 +22,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore' import { useMapStore } from '@/stores/mapStore'
import { onClickOutside, useFocus } from '@vueuse/core' import { onClickOutside, useFocus } from '@vueuse/core'
@ -30,7 +31,6 @@ import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const scene = useScene() const scene = useScene()
const gameStore = useGameStore() const gameStore = useGameStore()
const mapStore = useMapStore()
const message = ref('') const message = ref('')
const chats = ref<{ character: string; message: string }[]>([]) const chats = ref<{ character: string; message: string }[]>([])
@ -55,7 +55,7 @@ function unfocusChat(event: Event, targetElement: HTMLElement) {
const sendMessage = () => { const sendMessage = () => {
if (!message.value.trim()) return if (!message.value.trim()) return
gameStore.connection?.emit(SocketEvent.CHAT_MESSAGE, { message: message.value }, (response: boolean) => {}) socketManager.emit(SocketEvent.CHAT_MESSAGE, { message: message.value }, (response: boolean) => {})
message.value = '' message.value = ''
} }
@ -79,7 +79,7 @@ const scrollToBottom = () => {
}) })
} }
gameStore.connection?.on(SocketEvent.CHAT_MESSAGE, (data: { character: string; message: string }) => { socketManager.on(SocketEvent.CHAT_MESSAGE, (data: { character: string; message: string }) => {
if (!data.character || !data.message) return if (!data.character || !data.message) return
chats.value.push({ character: data.character, message: data.message }) chats.value.push({ character: data.character, message: data.message })
@ -153,7 +153,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
gameStore.connection?.off(SocketEvent.CHAT_MESSAGE) socketManager.off(SocketEvent.CHAT_MESSAGE)
removeEventListener('keydown', focusChat) removeEventListener('keydown', focusChat)
}) })
</script> </script>

View File

@ -8,6 +8,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useDateFormat } from '@vueuse/core' import { useDateFormat } from '@vueuse/core'
import { onUnmounted } from 'vue' import { onUnmounted } from 'vue'
@ -15,6 +16,6 @@ import { onUnmounted } from 'vue'
const gameStore = useGameStore() const gameStore = useGameStore()
onUnmounted(() => { onUnmounted(() => {
gameStore.connection?.off(SocketEvent.DATE) socketManager.off(SocketEvent.DATE)
}) })
</script> </script>

View File

@ -6,6 +6,7 @@
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { MapCharacter, UUID } from '@/application/types' import type { MapCharacter, UUID } from '@/application/types'
import Character from '@/components/game/character/Character.vue' import Character from '@/components/game/character/Character.vue'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore' import { useMapStore } from '@/stores/mapStore'
import { onUnmounted } from 'vue' import { onUnmounted } from 'vue'
@ -17,32 +18,32 @@ const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap tileMap: Phaser.Tilemaps.Tilemap
}>() }>()
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_JOIN, (data: MapCharacter) => { socketManager.on(SocketEvent.MAP_CHARACTER_JOIN, (data: MapCharacter) => {
mapStore.addCharacter(data) mapStore.addCharacter(data)
}) })
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_LEAVE, (characterId: UUID) => { socketManager.on(SocketEvent.MAP_CHARACTER_LEAVE, (characterId: UUID) => {
mapStore.removeCharacter(characterId) mapStore.removeCharacter(characterId)
}) })
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_MOVE, (data: { characterId: UUID; positionX: number; positionY: number; rotation: number; isMoving: boolean }) => { socketManager.on(SocketEvent.MAP_CHARACTER_MOVE, ([characterId, posX, posY, rot, isMoving]: [UUID, number, number, number, boolean]) => {
mapStore.updateCharacterPosition(data) mapStore.updateCharacterPosition([characterId, posX, posY, rot, isMoving])
// @TODO: Replace with universal class, composable or store
if (data.characterId === gameStore.character?.id) { if (characterId === gameStore.character?.id) {
gameStore.character!.positionX = data.positionX gameStore.character!.positionX = posX
gameStore.character!.positionY = data.positionY gameStore.character!.positionY = posY
gameStore.character!.rotation = data.rotation gameStore.character!.rotation = rot
} }
}) })
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_ATTACK, (characterId: UUID) => { socketManager.on(SocketEvent.MAP_CHARACTER_ATTACK, (characterId: UUID) => {
mapStore.updateCharacterProperty(characterId, 'isAttacking', true) mapStore.updateCharacterProperty(characterId, 'isAttacking', true)
}) })
onUnmounted(() => { onUnmounted(() => {
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_ATTACK) socketManager.off(SocketEvent.MAP_CHARACTER_ATTACK)
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_MOVE) socketManager.off(SocketEvent.MAP_CHARACTER_MOVE)
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_JOIN) socketManager.off(SocketEvent.MAP_CHARACTER_JOIN)
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_LEAVE) socketManager.off(SocketEvent.MAP_CHARACTER_LEAVE)
}) })
</script> </script>

View File

@ -11,6 +11,7 @@ import { unduplicateArray } from '@/application/utilities'
import Characters from '@/components/game/map/Characters.vue' import Characters from '@/components/game/map/Characters.vue'
import MapTiles from '@/components/game/map/MapTiles.vue' import MapTiles from '@/components/game/map/MapTiles.vue'
import PlacedMapObjects from '@/components/game/map/PlacedMapObjects.vue' import PlacedMapObjects from '@/components/game/map/PlacedMapObjects.vue'
import { socketManager } from '@/managers/SocketManager'
import { createTileLayer, createTileMap, loadTileTexturesFromMapTileArray } from '@/services/mapService' import { createTileLayer, createTileMap, loadTileTexturesFromMapTileArray } from '@/services/mapService'
import { MapStorage } from '@/storage/storages' import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
@ -29,7 +30,7 @@ const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
const tileMapLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>() const tileMapLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
// Event listeners // Event listeners
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_TELEPORT, (data: mapLoadData) => { socketManager.on(SocketEvent.MAP_CHARACTER_TELEPORT, (data: mapLoadData) => {
mapStore.setMapId(data.mapId) mapStore.setMapId(data.mapId)
mapStore.setCharacters(data.characters) mapStore.setCharacters(data.characters)
}) })
@ -65,6 +66,6 @@ onUnmounted(() => {
tileMap.value.destroy() tileMap.value.destroy()
} }
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_TELEPORT) socketManager.off(SocketEvent.MAP_CHARACTER_TELEPORT)
}) })
</script> </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> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config'
import type { MapObject, PlacedMapObject } from '@/application/types' import type { MapObject, PlacedMapObject } from '@/application/types'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import ImageGroup from '@/components/game/map/partials/ImageGroup.vue'
import { calculateIsometricDepth, loadMapObjectTextures, tileToWorldXY } from '@/services/mapService' import { loadMapObjectTextures, tileToWorldXY } from '@/services/mapService'
import { MapObjectStorage } from '@/storage/storages' import { MapObjectStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { Image, useScene } from 'phavuer' import { useScene } from 'phavuer'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import Tilemap = Phaser.Tilemaps.Tilemap import Tilemap = Phaser.Tilemaps.Tilemap
import TilemapLayer = Phaser.Tilemaps.TilemapLayer import TilemapLayer = Phaser.Tilemaps.TilemapLayer
@ -24,10 +23,15 @@ const props = defineProps<{
const scene = useScene() const scene = useScene()
const gameStore = useGameStore() const gameStore = useGameStore()
const mapEditor = useMapEditorComposable()
const mapObject = ref<MapObject>() const mapObject = ref<MapObject>()
const groupProps = computed(() => ({
...calculateObjectPlacement(props.placedMapObject),
mapObj: mapObject.value,
obj: props.placedMapObject
}))
async function initialize() { async function initialize() {
if (!props.placedMapObject.mapObject) return if (!props.placedMapObject.mapObject) return
@ -44,6 +48,8 @@ async function initialize() {
const _mapObject = await mapObjectStorage.getById(props.placedMapObject.mapObject as string) const _mapObject = await mapObjectStorage.getById(props.placedMapObject.mapObject as string)
if (!_mapObject) return if (!_mapObject) return
console.log(_mapObject)
mapObject.value = _mapObject mapObject.value = _mapObject
await loadMapObjectTextures([_mapObject], scene) 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) let position = tileToWorldXY(props.tileMapLayer, mapObj.positionX, mapObj.positionY)
return { return {
x: position.worldPositionX - mapObject.value!.frameWidth / 2, x: position.worldPositionX,
y: position.worldPositionY - mapObject.value!.frameHeight / 2 + config.tile_size.height 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, mapObject.value!.frameWidth, mapObject.value!.frameHeight, mapObject.value!.originX, mapObject.value!.originY),
...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 () => { onMounted(async () => {
await initialize() await initialize()
}) })

View File

@ -20,12 +20,29 @@
</select> </select>
</div> </div>
<div class="form-field-full"> <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> <label for="spriteId">Sprite</label>
<select v-model="characterSpriteId" class="input-field" name="spriteId"> <select v-model="characterSpriteId" class="input-field" name="spriteId">
<option disabled selected value="">Select sprite</option> <option disabled selected value="">Select sprite</option>
<option v-for="sprite in assetManagerStore.spriteList" :key="sprite.id" :value="sprite.id">{{ sprite.name }}</option> <option v-for="sprite in assetManagerStore.spriteList" :key="sprite.id" :value="sprite.id">{{ sprite.name }}</option>
</select> </select>
</div> </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-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> <button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.prevent="removeCharacterHair">Remove</button>
</form> </form>
@ -34,49 +51,50 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { CharacterGender, CharacterHair, Sprite } from '@/application/types' 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 { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore() const assetManagerStore = useAssetManagerStore()
const selectedCharacterHair = computed(() => assetManagerStore.selectedCharacterHair) const selectedCharacterHair = computed(() => assetManagerStore.selectedCharacterHair)
const characterName = ref('') const characterName = ref('')
const characterGender = ref<CharacterGender>('MALE' as CharacterGender.MALE) const characterGender = ref<CharacterGender>('MALE' as CharacterGender.MALE)
const characterColor = ref<string>('#000000')
const characterIsSelectable = ref<boolean>(false) const characterIsSelectable = ref<boolean>(false)
const characterSpriteId = ref<string | null | undefined>(null) const characterSpriteId = ref<string | null | undefined>(null)
const genderOptions: CharacterGender[] = ['MALE' as CharacterGender.MALE, 'FEMALE' as CharacterGender.FEMALE] const genderOptions: CharacterGender[] = ['MALE' as CharacterGender.MALE, 'FEMALE' as CharacterGender.FEMALE]
if (!selectedCharacterHair.value) {
console.error('No character hair selected')
}
if (selectedCharacterHair.value) { if (selectedCharacterHair.value) {
characterName.value = selectedCharacterHair.value.name characterName.value = selectedCharacterHair.value.name
characterGender.value = selectedCharacterHair.value.gender characterGender.value = selectedCharacterHair.value.gender
characterColor.value = selectedCharacterHair.value.color
characterIsSelectable.value = selectedCharacterHair.value.isSelectable characterIsSelectable.value = selectedCharacterHair.value.isSelectable
characterSpriteId.value = selectedCharacterHair.value.sprite?.id characterSpriteId.value = selectedCharacterHair.value.sprite?.id
} }
function removeCharacterHair() { async function removeCharacterHair() {
if (!selectedCharacterHair.value) return if (!selectedCharacterHair.value) return
gameStore.connection?.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) { if (!response) {
console.error('Failed to remove character hair') console.error('Failed to remove character hair')
return return
} }
refreshCharacterHairList()
await downloadCache('character_hair', new CharacterHairStorage())
await refreshCharacterHairList()
}) })
} }
function refreshCharacterHairList(unsetSelectedCharacterHair = true) { async function refreshCharacterHairList(unsetSelectedCharacterHair = true) {
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => { socketManager.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => {
assetManagerStore.setCharacterHairList(response) assetManagerStore.setCharacterHairList(response)
if (unsetSelectedCharacterHair) { if (unsetSelectedCharacterHair) {
@ -85,21 +103,24 @@ function refreshCharacterHairList(unsetSelectedCharacterHair = true) {
}) })
} }
function saveCharacterHair() { async function saveCharacterHair() {
const characterHairData = { const characterHairData = {
id: selectedCharacterHair.value!.id, id: selectedCharacterHair.value!.id,
name: characterName.value, name: characterName.value,
gender: characterGender.value, gender: characterGender.value,
color: characterColor.value,
isSelectable: characterIsSelectable.value, isSelectable: characterIsSelectable.value,
spriteId: characterSpriteId.value spriteId: characterSpriteId.value
} }
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_UPDATE, characterHairData, (response: boolean) => { socketManager.emit(SocketEvent.GM_CHARACTERHAIR_UPDATE, characterHairData, async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to save character type') console.error('Failed to save character type')
return return
} }
refreshCharacterHairList(false)
await downloadCache('character_hair', new CharacterHairStorage())
await refreshCharacterHairList(false)
}) })
} }
@ -107,6 +128,7 @@ watch(selectedCharacterHair, (characterHair: CharacterHair | null) => {
if (!characterHair) return if (!characterHair) return
characterName.value = characterHair.name characterName.value = characterHair.name
characterGender.value = characterHair.gender characterGender.value = characterHair.gender
characterColor.value = characterHair.color
characterIsSelectable.value = characterHair.isSelectable characterIsSelectable.value = characterHair.isSelectable
characterSpriteId.value = characterHair.sprite?.id characterSpriteId.value = characterHair.sprite?.id
}) })
@ -114,7 +136,7 @@ watch(selectedCharacterHair, (characterHair: CharacterHair | null) => {
onMounted(() => { onMounted(() => {
if (!selectedCharacterHair.value) return if (!selectedCharacterHair.value) return
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => { socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response) assetManagerStore.setSpriteList(response)
}) })
}) })

View File

@ -34,6 +34,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { CharacterHair } from '@/application/types' import type { CharacterHair } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -53,13 +54,13 @@ const handleSearch = () => {
} }
const createNewCharacterHair = () => { const createNewCharacterHair = () => {
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_CREATE, {}, (response: boolean) => { socketManager.emit(SocketEvent.GM_CHARACTERHAIR_CREATE, {}, (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to create new character type') console.error('Failed to create new character type')
return return
} }
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => { socketManager.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => {
assetManagerStore.setCharacterHairList(response) assetManagerStore.setCharacterHairList(response)
}) })
}) })
@ -93,7 +94,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => { socketManager.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => {
assetManagerStore.setCharacterHairList(response) assetManagerStore.setCharacterHairList(response)
}) })
}) })

View File

@ -42,11 +42,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { CharacterGender, CharacterRace, CharacterType, Sprite } from '@/application/types' 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 { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore() const assetManagerStore = useAssetManagerStore()
const selectedCharacterType = computed(() => assetManagerStore.selectedCharacterType) const selectedCharacterType = computed(() => assetManagerStore.selectedCharacterType)
@ -72,20 +73,22 @@ if (selectedCharacterType.value) {
characterSpriteId.value = selectedCharacterType.value.sprite?.id characterSpriteId.value = selectedCharacterType.value.sprite?.id
} }
function removeCharacterType() { async function removeCharacterType() {
if (!selectedCharacterType.value) return if (!selectedCharacterType.value) return
gameStore.connection?.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) { if (!response) {
console.error('Failed to remove character type') console.error('Failed to remove character type')
return return
} }
refreshCharacterTypeList()
await downloadCache('character_types', new CharacterTypeStorage())
await refreshCharacterTypeList()
}) })
} }
function refreshCharacterTypeList(unsetSelectedCharacterType = true) { async function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => { socketManager.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => {
assetManagerStore.setCharacterTypeList(response) assetManagerStore.setCharacterTypeList(response)
if (unsetSelectedCharacterType) { if (unsetSelectedCharacterType) {
@ -94,7 +97,7 @@ function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
}) })
} }
function saveCharacterType() { async function saveCharacterType() {
const characterTypeData = { const characterTypeData = {
id: selectedCharacterType.value!.id, id: selectedCharacterType.value!.id,
name: characterName.value, name: characterName.value,
@ -104,12 +107,14 @@ function saveCharacterType() {
spriteId: characterSpriteId.value spriteId: characterSpriteId.value
} }
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_UPDATE, characterTypeData, (response: boolean) => { socketManager.emit(SocketEvent.GM_CHARACTERTYPE_UPDATE, characterTypeData, async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to save character type') console.error('Failed to save character type')
return return
} }
refreshCharacterTypeList(false)
await downloadCache('character_types', new CharacterTypeStorage())
await refreshCharacterTypeList(false)
}) })
} }
@ -125,7 +130,7 @@ watch(selectedCharacterType, (characterType: CharacterType | null) => {
onMounted(() => { onMounted(() => {
if (!selectedCharacterType.value) return if (!selectedCharacterType.value) return
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => { socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response) assetManagerStore.setSpriteList(response)
}) })
}) })

View File

@ -34,6 +34,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { CharacterType } from '@/application/types' import type { CharacterType } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -53,13 +54,13 @@ const handleSearch = () => {
} }
const createNewCharacterType = () => { const createNewCharacterType = () => {
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_CREATE, {}, (response: boolean) => { socketManager.emit(SocketEvent.GM_CHARACTERTYPE_CREATE, {}, (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to create new character type') console.error('Failed to create new character type')
return return
} }
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => { socketManager.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => {
assetManagerStore.setCharacterTypeList(response) assetManagerStore.setCharacterTypeList(response)
}) })
}) })
@ -93,7 +94,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => { socketManager.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => {
assetManagerStore.setCharacterTypeList(response) assetManagerStore.setCharacterTypeList(response)
}) })
}) })

View File

@ -46,6 +46,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { Item, ItemRarity, ItemType, Sprite } from '@/application/types' import type { Item, ItemRarity, ItemType, Sprite } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
@ -81,7 +82,7 @@ if (selectedItem.value) {
function removeItem() { function removeItem() {
if (!selectedItem.value) return if (!selectedItem.value) return
gameStore.connection?.emit(SocketEvent.GM_ITEM_REMOVE, { id: selectedItem.value.id }, (response: boolean) => { socketManager.emit(SocketEvent.GM_ITEM_REMOVE, { id: selectedItem.value.id }, (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to remove item') console.error('Failed to remove item')
return return
@ -91,7 +92,7 @@ function removeItem() {
} }
function refreshItemList(unsetSelectedItem = true) { function refreshItemList(unsetSelectedItem = true) {
gameStore.connection?.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => { socketManager.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => {
assetManagerStore.setItemList(response) assetManagerStore.setItemList(response)
if (unsetSelectedItem) { if (unsetSelectedItem) {
@ -111,7 +112,7 @@ function saveItem() {
spriteId: itemSpriteId.value spriteId: itemSpriteId.value
} }
gameStore.connection?.emit(SocketEvent.GM_ITEM_UPDATE, itemData, (response: boolean) => { socketManager.emit(SocketEvent.GM_ITEM_UPDATE, itemData, (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to save item') console.error('Failed to save item')
return return
@ -133,7 +134,7 @@ watch(selectedItem, (item: Item | null) => {
onMounted(() => { onMounted(() => {
if (!selectedItem.value) return if (!selectedItem.value) return
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => { socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response) assetManagerStore.setSpriteList(response)
}) })
}) })

View File

@ -31,6 +31,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { Item } from '@/application/types' import type { Item } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -49,13 +50,13 @@ const handleSearch = () => {
} }
const createNewItem = () => { const createNewItem = () => {
gameStore.connection?.emit(SocketEvent.GM_ITEM_CREATE, {}, (response: boolean) => { socketManager.emit(SocketEvent.GM_ITEM_CREATE, {}, (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to create new item') console.error('Failed to create new item')
return return
} }
gameStore.connection?.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => { socketManager.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => {
assetManagerStore.setItemList(response) assetManagerStore.setItemList(response)
}) })
}) })
@ -89,7 +90,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
gameStore.connection?.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => { socketManager.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => {
assetManagerStore.setItemList(response) assetManagerStore.setItemList(response)
}) })
}) })

View File

@ -1,8 +1,30 @@
<template> <template>
<div class="h-full overflow-auto"> <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 p-2.5 flex flex-col items-center justify-center h-72 rounded-md default-border bg-gray">
<img class="max-h-56" :src="`${config.server_endpoint}/textures/map_objects/${selectedMapObject?.id}.png`" :alt="'Object ' + selectedMapObject?.id" /> <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>
<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"> <div class="mt-5 block">
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveObject"> <form class="flex gap-2.5 flex-wrap" @submit.prevent="saveObject">
<div class="form-field-full"> <div class="form-field-full">
@ -46,23 +68,32 @@
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { MapObject } from '@/application/types' import type { MapObject } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import ChipsInput from '@/components/forms/ChipsInput.vue' import ChipsInput from '@/components/forms/ChipsInput.vue'
import { socketManager } from '@/managers/SocketManager'
import { MapObjectStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useElementSize } from '@vueuse/core'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { Rectangle } from 'phavuer'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore() const assetManagerStore = useAssetManagerStore()
const selectedMapObject = computed(() => assetManagerStore.selectedMapObject) const selectedMapObject = computed(() => assetManagerStore.selectedMapObject)
const svg = useTemplateRef('svg')
const { width, height } = useElementSize(svg)
const mapObjectName = ref('') const mapObjectName = ref('')
const mapObjectTags = ref<string[]>([]) const mapObjectTags = ref<string[]>([])
const mapObjectDepthOffsets = ref<number[]>([])
const mapObjectOriginX = ref(0) const mapObjectOriginX = ref(0)
const mapObjectOriginY = ref(0) const mapObjectOriginY = ref(0)
const mapObjectFrameRate = ref(0) const mapObjectFrameRate = ref(0)
const mapObjectFrameWidth = ref(0) const mapObjectFrameWidth = ref(0)
const mapObjectFrameHeight = ref(0) const mapObjectFrameHeight = ref(0)
const imageRef = ref<HTMLImageElement | null>(null)
const showOrigin = ref(true)
const showPartitionOverlay = ref(true)
if (!selectedMapObject.value) { if (!selectedMapObject.value) {
console.error('No map mapObject selected') console.error('No map mapObject selected')
@ -71,6 +102,7 @@ if (!selectedMapObject.value) {
if (selectedMapObject.value) { if (selectedMapObject.value) {
mapObjectName.value = selectedMapObject.value.name mapObjectName.value = selectedMapObject.value.name
mapObjectTags.value = selectedMapObject.value.tags mapObjectTags.value = selectedMapObject.value.tags
mapObjectDepthOffsets.value = selectedMapObject.value.depthOffsets
mapObjectOriginX.value = selectedMapObject.value.originX mapObjectOriginX.value = selectedMapObject.value.originX
mapObjectOriginY.value = selectedMapObject.value.originY mapObjectOriginY.value = selectedMapObject.value.originY
mapObjectFrameRate.value = selectedMapObject.value.frameRate mapObjectFrameRate.value = selectedMapObject.value.frameRate
@ -78,18 +110,23 @@ if (selectedMapObject.value) {
mapObjectFrameHeight.value = selectedMapObject.value.frameHeight mapObjectFrameHeight.value = selectedMapObject.value.frameHeight
} }
function removeObject() { const setPartitionDepth = (event: any, idx: number) => (mapObjectDepthOffsets.value[idx] = Number.parseInt(event.target.value))
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_REMOVE, { mapObject: selectedMapObject.value?.id }, (response: boolean) => {
async function removeObject() {
if (!selectedMapObject.value) return
socketManager.emit(SocketEvent.GM_MAPOBJECT_REMOVE, { mapObjectId: selectedMapObject.value.id }, async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to remove mapObject') console.error('Failed to remove mapObject')
return return
} }
refreshObjectList()
await downloadCache('map_objects', new MapObjectStorage())
await refreshObjectList()
}) })
} }
function refreshObjectList(unsetSelectedMapObject = true) { async function refreshObjectList(unsetSelectedMapObject = true) {
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => { socketManager.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => {
assetManagerStore.setMapObjectList(response) assetManagerStore.setMapObjectList(response)
if (unsetSelectedMapObject) { if (unsetSelectedMapObject) {
@ -98,30 +135,32 @@ function refreshObjectList(unsetSelectedMapObject = true) {
}) })
} }
function saveObject() { async function saveObject() {
if (!selectedMapObject.value) { if (!selectedMapObject.value) {
console.error('No mapObject selected') console.error('No mapObject selected')
return return
} }
socketManager.emit(
gameStore.connection?.emit( SocketEvent.GM_MAPOBJECT_UPDATE,
'gm:mapObject:update',
{ {
id: selectedMapObject.value.id, id: selectedMapObject.value.id,
name: mapObjectName.value, name: mapObjectName.value,
tags: mapObjectTags.value, tags: mapObjectTags.value,
depthOffsets: mapObjectDepthOffsets.value,
originX: mapObjectOriginX.value, originX: mapObjectOriginX.value,
originY: mapObjectOriginY.value, originY: mapObjectOriginY.value,
frameRate: mapObjectFrameRate.value, frameRate: mapObjectFrameRate.value,
frameWidth: mapObjectFrameWidth.value, frameWidth: mapObjectFrameWidth.value,
frameHeight: mapObjectFrameHeight.value frameHeight: mapObjectFrameHeight.value
}, },
(response: boolean) => { async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to save mapObject') console.error('Failed to save mapObject')
return return
} }
refreshObjectList(false)
await downloadCache('map_objects', new MapObjectStorage())
await refreshObjectList(false)
} }
) )
} }
@ -130,6 +169,7 @@ watch(selectedMapObject, (mapObject: MapObject | null) => {
if (!mapObject) return if (!mapObject) return
mapObjectName.value = mapObject.name mapObjectName.value = mapObject.name
mapObjectTags.value = mapObject.tags mapObjectTags.value = mapObject.tags
mapObjectDepthOffsets.value = mapObject.depthOffsets
mapObjectOriginX.value = mapObject.originX mapObjectOriginX.value = mapObject.originX
mapObjectOriginY.value = mapObject.originY mapObjectOriginY.value = mapObject.originY
mapObjectFrameRate.value = mapObject.frameRate mapObjectFrameRate.value = mapObject.frameRate
@ -141,7 +181,37 @@ onMounted(() => {
if (!selectedMapObject.value) return if (!selectedMapObject.value) return
}) })
// 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(() => { onBeforeUnmount(() => {
assetManagerStore.setSelectedMapObject(null) assetManagerStore.setSelectedMapObject(null)
}) })
</script> </script>
<style scoped>
.pointer-events-none {
pointer-events: none;
}
</style>

View File

@ -31,6 +31,7 @@
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { MapObject } from '@/application/types' import type { MapObject } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -48,13 +49,13 @@ const elementToScroll = ref()
const handleFileUpload = (e: Event) => { const handleFileUpload = (e: Event) => {
const files = (e.target as HTMLInputElement).files const files = (e.target as HTMLInputElement).files
if (!files) return if (!files) return
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_UPLOAD, files, (response: boolean) => { socketManager.emit(SocketEvent.GM_MAPOBJECT_UPLOAD, files, (response: boolean) => {
if (!response) { if (!response) {
if (config.environment === 'development') console.error('Failed to upload map object') if (config.environment === 'development') console.error('Failed to upload map object')
return return
} }
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => { socketManager.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => {
assetManagerStore.setMapObjectList(response) assetManagerStore.setMapObjectList(response)
}) })
}) })
@ -93,7 +94,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => { socketManager.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => {
assetManagerStore.setMapObjectList(response) assetManagerStore.setMapObjectList(response)
}) })
}) })

View File

@ -1,12 +1,11 @@
<template> <template>
<div class="h-full overflow-auto"> <div class="h-full overflow-auto">
<div class="relative flex flex-col"> <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"> <div class="w-full flex flex-col">
<label class="mb-1.5 font-titles" for="name">Name</label> <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" /> <input v-model="spriteName" class="input-field" type="text" name="name" placeholder="New sprite" />
</div> </div>
<div class="w-full flex gap-2 mt-2 pb-4 relative"> <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-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> <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" /> <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> </svg>
</button> </button>
<button class="btn-cyan px-4" type="button" @click.prevent="addNewImage">New action</button>
</div> </div>
</div> </div>
<div v-for="action in spriteActions" :key="action.id">
<button class="btn-cyan py-2 my-4" type="button" @click.prevent="addNewImage">New action</button> <div class="flex flex-wrap gap-3 mb-3">
<Accordion v-for="action in spriteActions" :key="action.id"> <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">
<template #header> <img :src="image.url" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" @load="updateImageDimensions($event, index)" />
<div class="flex items-center"> <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>
{{ 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>
</div> </div>
</template> <div class="flex items-center mb-3">
<template #content> <div class="mr-3 space-x-2">
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveSprite"> <button class="btn-cyan px-4 py-1.5 min-w-24 text-left" type="button" @click.stop.prevent="openEditorModal(action)">
<div class="form-field-full"> Editor
<label for="action">Action</label> <div class="flex">
<input v-model="action.action" class="input-field" type="text" name="action" placeholder="Action" /> <small class="text-xs font-default">{{ action.action }}</small>
</div> </div>
<div class="form-field-half"> </button>
<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>
<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>
<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>
<div class="form-field-full"> <SpriteEditor
<SpriteActionsInput v-model="action.sprites" @tempOffsetChange="(index, offset) => handleTempOffsetChange(action, index, offset)" /> v-for="[actionId, editorData] in Array.from(openEditors.entries())"
</div> :key="actionId"
</form> :sprite="selectedSprite!"
</template> :sprites="editorData.action.sprites"
</Accordion> :frame-rate="editorData.action.frameRate"
<SpritePreview :is-modal-open="editorData.isOpen"
v-if="selectedAction" :temp-offset-index="getTempOffsetIndex(editorData.action)"
:sprites="selectedAction.sprites" :temp-offset="getTempOffset(editorData.action)"
:frame-rate="selectedAction.frameRate" @update:frame-rate="(value) => updateFrameRate(editorData.action, value)"
:is-modal-open="isModalOpen" @update:is-modal-open="(value) => handleEditorModalClose(editorData.action, value)"
:temp-offset-index="tempOffsetData.index" @update:temp-offset="(index, offset) => handleTempOffsetChange(editorData.action, index, offset)"
:temp-offset="tempOffsetData.offset"
@update:frame-rate="updateFrameRate"
@update:is-modal-open="isModalOpen = $event"
/> />
</div> </div>
</div> </div>
@ -69,24 +54,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { Sprite, SpriteAction, UUID } from '@/application/types' import type { Sprite, SpriteAction } from '@/application/types'
import { uuidv4 } from '@/application/utilities' import { downloadCache, uuidv4 } from '@/application/utilities'
import SpriteActionsInput from '@/components/gameMaster/assetManager/partials/sprite/partials/SpriteImagesInput.vue' import SpriteEditor from '@/components/gameMaster/assetManager/partials/sprite/partials/SpriteEditor.vue'
import SpritePreview from '@/components/gameMaster/assetManager/partials/sprite/partials/SpritePreview.vue' import { socketManager } from '@/managers/SocketManager'
import Accordion from '@/components/utilities/Accordion.vue' import { SpriteStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore() const assetManagerStore = useAssetManagerStore()
const selectedSprite = computed(() => assetManagerStore.selectedSprite) 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 spriteName = ref('')
const spriteActions = ref<SpriteAction[]>([]) 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) { if (!selectedSprite.value) {
console.error('No sprite selected') console.error('No sprite selected')
@ -97,28 +80,32 @@ if (selectedSprite.value) {
spriteActions.value = sortSpriteActions(selectedSprite.value.spriteActions) spriteActions.value = sortSpriteActions(selectedSprite.value.spriteActions)
} }
function deleteSprite() { async function deleteSprite() {
gameStore.connection?.emit(SocketEvent.GM_SPRITE_DELETE, { id: selectedSprite.value?.id }, (response: boolean) => { socketManager.emit(SocketEvent.GM_SPRITE_DELETE, { id: selectedSprite.value?.id }, async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to delete sprite') console.error('Failed to delete sprite')
return return
} }
refreshSpriteList()
await downloadCache('sprites', new SpriteStorage())
await refreshSpriteList()
}) })
} }
function copySprite() { async function copySprite() {
gameStore.connection?.emit(SocketEvent.GM_SPRITE_COPY, { id: selectedSprite.value?.id }, (response: boolean) => { socketManager.emit(SocketEvent.GM_SPRITE_COPY, { id: selectedSprite.value?.id }, async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to copy sprite') console.error('Failed to copy sprite')
return return
} }
refreshSpriteList(false)
await downloadCache('sprites', new SpriteStorage())
await refreshSpriteList(false)
}) })
} }
function refreshSpriteList(unsetSelectedSprite = true) { async function refreshSpriteList(unsetSelectedSprite = true) {
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => { socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response) assetManagerStore.setSpriteList(response)
if (unsetSelectedSprite) { if (unsetSelectedSprite) {
@ -127,7 +114,7 @@ function refreshSpriteList(unsetSelectedSprite = true) {
}) })
} }
function saveSprite() { async function saveSprite() {
if (!selectedSprite.value) { if (!selectedSprite.value) {
console.error('No sprite selected') console.error('No sprite selected')
return return
@ -150,12 +137,14 @@ function saveSprite() {
}) ?? [] }) ?? []
} }
gameStore.connection?.emit(SocketEvent.GM_SPRITE_UPDATE, updatedSprite, (response: boolean) => { socketManager.emit(SocketEvent.GM_SPRITE_UPDATE, updatedSprite, async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to save sprite') console.error('Failed to save sprite')
return return
} }
refreshSpriteList(false)
await downloadCache('sprites', new SpriteStorage())
await refreshSpriteList(false)
}) })
} }
@ -186,39 +175,69 @@ function sortSpriteActions(actions: SpriteAction[]): SpriteAction[] {
return [...actions].sort((a, b) => a.action.localeCompare(b.action)) return [...actions].sort((a, b) => a.action.localeCompare(b.action))
} }
function openPreviewModal(action: SpriteAction) { function openEditorModal(action: SpriteAction) {
selectedAction.value = action const newOpenEditors = new Map(openEditors.value)
isModalOpen.value = true newOpenEditors.set(action.id, { action, isOpen: true })
openEditors.value = newOpenEditors
} }
function updateFrameRate(value: number) { function updateFrameRate(action: SpriteAction, value: number) {
if (selectedAction.value) { console.log('update frame rate', action)
selectedAction.value.frameRate = value action.frameRate = value
}
} }
const tempOffsetData = ref<{ index: number | undefined; offset: { x: number; y: number } | undefined }>({ function handleEditorModalClose(action: SpriteAction, isOpen: boolean) {
index: undefined, if (isOpen) return
offset: undefined const newOpenEditors = new Map(openEditors.value)
}) newOpenEditors.delete(action.id)
openEditors.value = newOpenEditors
}
function handleTempOffsetChange(action: SpriteAction, index: number, offset: { x: number; y: number }) { function handleTempOffsetChange(action: SpriteAction, index: number, offset: { x: number; y: number }) {
if (selectedAction.value === action) { // Update the temporary offset data for this action
tempOffsetData.value = { index, offset } 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) => { watch(selectedSprite, (sprite: Sprite | null) => {
if (!sprite) return if (!sprite) return
spriteName.value = sprite.name spriteName.value = sprite.name
spriteActions.value = sortSpriteActions(sprite.spriteActions) spriteActions.value = sortSpriteActions(sprite.spriteActions)
openEditors.value = new Map()
tempOffsetData.value = new Map() // Reset temp offset data when sprite changes
}) })
watch(isModalOpen, (newValue) => { interface SpriteImage {
if (!newValue) { url: string
selectedAction.value = null 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(() => { onMounted(() => {
if (!selectedSprite.value) return if (!selectedSprite.value) return

View File

@ -27,6 +27,7 @@
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { Sprite } from '@/application/types' import type { Sprite } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -41,13 +42,13 @@ const hasScrolled = ref(false)
const elementToScroll = ref() const elementToScroll = ref()
function newButtonClickHandler() { function newButtonClickHandler() {
gameStore.connection?.emit(SocketEvent.GM_SPRITE_CREATE, {}, (response: boolean) => { socketManager.emit(SocketEvent.GM_SPRITE_CREATE, {}, (response: boolean) => {
if (!response) { if (!response) {
if (config.environment === 'development') console.error('Failed to create new sprite') if (config.environment === 'development') console.error('Failed to create new sprite')
return return
} }
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => { socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response) assetManagerStore.setSpriteList(response)
}) })
}) })
@ -86,7 +87,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => { socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response) assetManagerStore.setSpriteList(response)
}) })
}) })

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,15 +26,14 @@
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { Tile } from '@/application/types' import type { Tile } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import ChipsInput from '@/components/forms/ChipsInput.vue' import ChipsInput from '@/components/forms/ChipsInput.vue'
import { socketManager } from '@/managers/SocketManager'
import { TileStorage } from '@/storage/storages' import { TileStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore() const assetManagerStore = useAssetManagerStore()
const tileStorage = new TileStorage()
const selectedTile = computed(() => assetManagerStore.selectedTile) const selectedTile = computed(() => assetManagerStore.selectedTile)
@ -57,18 +56,19 @@ watch(selectedTile, (tile: Tile | null) => {
}) })
async function deleteTile() { async function deleteTile() {
gameStore.connection?.emit(SocketEvent.GM_TILE_DELETE, { id: selectedTile.value?.id }, async (response: boolean) => { socketManager.emit(SocketEvent.GM_TILE_DELETE, { id: selectedTile.value?.id }, async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to delete tile') console.error('Failed to delete tile')
return return
} }
await tileStorage.delete(selectedTile.value!.id)
refreshTileList() await downloadCache('tiles', new TileStorage())
await refreshTileList()
}) })
} }
function refreshTileList(unsetSelectedTile = true) { async function refreshTileList(unsetSelectedTile = true) {
gameStore.connection?.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => { socketManager.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => {
assetManagerStore.setTileList(response) assetManagerStore.setTileList(response)
if (unsetSelectedTile) { if (unsetSelectedTile) {
@ -77,25 +77,27 @@ function refreshTileList(unsetSelectedTile = true) {
}) })
} }
function saveTile() { async function saveTile() {
if (!selectedTile.value) { if (!selectedTile.value) {
console.error('No tile selected') console.error('No tile selected')
return return
} }
gameStore.connection?.emit( socketManager.emit(
'gm:tile:update', 'gm:tile:update',
{ {
id: selectedTile.value.id, id: selectedTile.value.id,
name: tileName.value, name: tileName.value,
tags: tileTags.value tags: tileTags.value
}, },
(response: boolean) => { async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to save tile') console.error('Failed to save tile')
return return
} }
refreshTileList(false)
await downloadCache('tiles', new TileStorage())
await refreshTileList(false)
} }
) )
} }

View File

@ -31,6 +31,7 @@
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { Tile } from '@/application/types' import type { Tile } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -48,13 +49,13 @@ const elementToScroll = ref()
const handleFileUpload = (e: Event) => { const handleFileUpload = (e: Event) => {
const files = (e.target as HTMLInputElement).files const files = (e.target as HTMLInputElement).files
if (!files) return if (!files) return
gameStore.connection?.emit(SocketEvent.GM_TILE_UPLOAD, files, (response: boolean) => { socketManager.emit(SocketEvent.GM_TILE_UPLOAD, files, (response: boolean) => {
if (!response) { if (!response) {
if (config.environment === 'development') console.error('Failed to upload tile') if (config.environment === 'development') console.error('Failed to upload tile')
return return
} }
gameStore.connection?.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => { socketManager.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => {
assetManagerStore.setTileList(response) assetManagerStore.setTileList(response)
}) })
}) })
@ -93,7 +94,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
gameStore.connection?.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => { socketManager.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => {
assetManagerStore.setTileList(response) assetManagerStore.setTileList(response)
}) })
}) })

View File

@ -10,9 +10,9 @@ import MapEventTiles from '@/components/gameMaster/mapEditor/mapPartials/MapEven
import MapTiles from '@/components/gameMaster/mapEditor/mapPartials/MapTiles.vue' import MapTiles from '@/components/gameMaster/mapEditor/mapPartials/MapTiles.vue'
import PlacedMapObjects from '@/components/gameMaster/mapEditor/mapPartials/PlacedMapObjects.vue' import PlacedMapObjects from '@/components/gameMaster/mapEditor/mapPartials/PlacedMapObjects.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' 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 { TileStorage } from '@/storage/storages'
import { useManualRefHistory, useRefHistory } from '@vueuse/core' import { useRefHistory } from '@vueuse/core'
import { useScene } from 'phavuer' import { useScene } from 'phavuer'
import { onBeforeUnmount, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue' import { onBeforeUnmount, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
@ -32,7 +32,7 @@ let commandIndex = ref(0)
let originTiles: string[][] = [] let originTiles: string[][] = []
let originEventTiles: MapEventTile[] = [] let originEventTiles: MapEventTile[] = []
let originObjects = ref<PlacedMapObjectT[]>(mapEditor.currentMap.value.placedMapObjects) let originObjects = ref<PlacedMapObjectT[]>(mapEditor.currentMap.value?.placedMapObjects ?? [])
const { undo, redo, commit, pause, resume, canUndo, canRedo } = useRefHistory(originObjects, { deep: true, capacity: 9 }) const { undo, redo, commit, pause, resume, canUndo, canRedo } = useRefHistory(originObjects, { deep: true, capacity: 9 })
@ -58,6 +58,7 @@ watch(
mapTiles.value!.clearTiles() mapTiles.value!.clearTiles()
eventTiles.value!.clearTiles() eventTiles.value!.clearTiles()
mapEditor.currentMap.value.placedMapObjects = [] mapEditor.currentMap.value.placedMapObjects = []
mapEditor.currentMap.value.mapEventTiles = []
updateAndCommit(mapEditor.currentMap.value) updateAndCommit(mapEditor.currentMap.value)
mapEditor.resetClearTilesFlag() mapEditor.resetClearTilesFlag()
} }
@ -234,6 +235,10 @@ onUnmounted(() => {
mapEditor.reset() mapEditor.reset()
}) })
setInterval(() => {
scene.children.queueDepthSort()
}, 0.2)
onBeforeUnmount(() => { onBeforeUnmount(() => {
removeEventListener('keydown', handleKeyDown) removeEventListener('keydown', handleKeyDown)
}) })

View File

@ -46,7 +46,9 @@ class EventTileCommand implements EditorCommand {
} }
} }
function createCommandUpdate(tile?: MapEventTile, operation: 'draw' | 'erase' | 'clear') { function createCommandUpdate(tile?: MapEventTile | null, operation: 'draw' | 'erase' | 'clear' = 'draw') {
if (!tile) return
if (!currentCommand) { if (!currentCommand) {
currentCommand = new EventTileCommand(operation) currentCommand = new EventTileCommand(operation)
} }
@ -86,19 +88,17 @@ function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
if (existingEventTile) return if (existingEventTile) return
// If teleport, check if there is a selected map // If teleport, check if there is a selected map
if (mapEditor.drawMode.value === 'teleport' && !mapEditor.teleportSettings.value.toMapId) return if (mapEditor.drawMode.value === 'teleport' && !mapEditor.teleportSettings.value.toMap) return
const newEventTile = { const newEventTile = {
id: uuidv4() as UUID, id: uuidv4() as UUID,
mapId: map.id,
map: map,
type: mapEditor.drawMode.value === 'blocking tile' ? MapEventTileType.BLOCK : MapEventTileType.TELEPORT, type: mapEditor.drawMode.value === 'blocking tile' ? MapEventTileType.BLOCK : MapEventTileType.TELEPORT,
positionX: tile.x, positionX: tile.x,
positionY: tile.y, positionY: tile.y,
teleport: teleport:
mapEditor.drawMode.value === 'teleport' mapEditor.drawMode.value === 'teleport'
? { ? {
toMap: mapEditor.teleportSettings.value.toMapId, toMap: mapEditor.teleportSettings.value.toMap,
toPositionX: mapEditor.teleportSettings.value.toPositionX, toPositionX: mapEditor.teleportSettings.value.toPositionX,
toPositionY: mapEditor.teleportSettings.value.toPositionY, toPositionY: mapEditor.teleportSettings.value.toPositionY,
toRotation: mapEditor.teleportSettings.value.toRotation toRotation: mapEditor.teleportSettings.value.toRotation
@ -106,9 +106,9 @@ function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
: undefined : undefined
} }
createCommandUpdate(newEventTile, 'draw') createCommandUpdate(newEventTile as MapEventTile, 'draw')
map.mapEventTiles.push(newEventTile) map.mapEventTiles.push(newEventTile as MapEventTile)
} }
function erase(pointer: Phaser.Input.Pointer, map: MapT) { function erase(pointer: Phaser.Input.Pointer, map: MapT) {
@ -149,7 +149,7 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
} }
function clearTiles() { function clearTiles() {
if (mapEditor.currentMap.value.mapEventTiles.length === 0) return if (mapEditor.currentMap.value?.mapEventTiles.length === 0) return
createCommandUpdate(null, 'clear') createCommandUpdate(null, 'clear')
finalizeCommand() finalizeCommand()
} }

View File

@ -7,7 +7,7 @@
:placedMapObject="previewPlacedMapObject as PlacedMapObjectT" :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" /> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -26,6 +26,7 @@ import TilemapLayer = Phaser.Tilemaps.TilemapLayer
const scene = useScene() const scene = useScene()
const mapEditor = useMapEditorComposable() const mapEditor = useMapEditorComposable()
const map = computed(() => mapEditor.currentMap.value!) 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 }>() const emit = defineEmits<{ (e: 'update', map: MapT): void; (e: 'updateAndCommit', map: MapT): void; (e: 'pauseObjectTracking'): void; (e: 'resumeObjectTracking'): void }>()
@ -129,7 +130,7 @@ function moveMapObject(id: string, map: MapT) {
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY) const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
if (!tile) return if (!tile) return
console.log(id)
map.placedMapObjects.map((placed) => { map.placedMapObjects.map((placed) => {
if (placed.id === id) { if (placed.id === id) {
placed.positionX = tile.x placed.positionX = tile.x

View File

@ -39,6 +39,7 @@ import { SocketEvent } from '@/application/enums'
import type { Map } from '@/application/types' import type { Map } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue' import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { socketManager } from '@/managers/SocketManager'
import { MapStorage } from '@/storage/storages' import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { ref, useTemplateRef } from 'vue' import { ref, useTemplateRef } from 'vue'
@ -57,7 +58,7 @@ const pvp = ref(false)
defineExpose({ open: () => modalRef.value?.open() }) defineExpose({ open: () => modalRef.value?.open() })
async function submit() { async function submit() {
gameStore.connection?.emit(SocketEvent.GM_MAP_CREATE, { name: name.value, width: width.value, height: height.value }, async (response: Map | false) => { socketManager.emit(SocketEvent.GM_MAP_CREATE, { name: name.value, width: width.value, height: height.value }, async (response: Map | false) => {
if (!response) { if (!response) {
return return
} }

View File

@ -1,17 +1,15 @@
<template> <template>
<Modal ref="modalRef" :is-resizable="false" :modal-width="300" :modal-height="360" bg-style="none"> <Modal ref="modalRef" :is-resizable="false" :modal-width="300" :modal-height="360" bg-style="none">
<template #modalHeader> <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>
<template #modalBody> <template #modalBody>
<div class="my-4 mx-auto h-full"> <div class="mx-auto h-full">
<div class="text-center mb-4 px-2 flex gap-2.5"> <div class="overflow-y-auto h-[calc(100%)]">
<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="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="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)"> <div class="flex gap-3 items-center w-full" @click="() => loadMap(map.id)">
<span>{{ map.name }}</span> <span>{{ map.name }}</span>
<span class="ml-auto gap-1 flex"> <span class="ml-auto gap-1 flex">
@ -24,7 +22,6 @@
</div> </div>
</template> </template>
</Modal> </Modal>
<CreateMap ref="createMapModal" @create="fetchMaps" /> <CreateMap ref="createMapModal" @create="fetchMaps" />
</template> </template>
@ -34,6 +31,7 @@ import type { Map } from '@/application/types'
import CreateMap from '@/components/gameMaster/mapEditor/partials/CreateMap.vue' import CreateMap from '@/components/gameMaster/mapEditor/partials/CreateMap.vue'
import Modal from '@/components/utilities/Modal.vue' import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { socketManager } from '@/managers/SocketManager'
import { MapStorage } from '@/storage/storages' import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { onMounted, ref, useTemplateRef } from 'vue' import { onMounted, ref, useTemplateRef } from 'vue'
@ -61,14 +59,14 @@ async function fetchMaps() {
} }
function loadMap(id: string) { function loadMap(id: string) {
gameStore.connection?.emit(SocketEvent.GM_MAP_REQUEST, { mapId: id }, (response: Map) => { socketManager.emit(SocketEvent.GM_MAP_REQUEST, { mapId: id }, (response: Map) => {
mapEditor.loadMap(response) mapEditor.loadMap(response)
}) })
modalRef.value?.close() modalRef.value?.close()
} }
async function deleteMap(id: string) { async function deleteMap(id: string) {
gameStore.connection?.emit(SocketEvent.GM_MAP_DELETE, { mapId: id }, async (response: boolean) => { socketManager.emit(SocketEvent.GM_MAP_DELETE, { mapId: id }, async (response: boolean) => {
if (!response) { if (!response) {
gameStore.addNotification({ gameStore.addNotification({
title: 'Error', title: 'Error',

View File

@ -1,11 +1,16 @@
<template> <template>
<div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'map_object'"> <div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'map_object'">
<div class="flex flex-col gap-2.5 p-2.5"> <div class="flex flex-col gap-2.5 p-2.5">
<div class="flex justify-between items-center">
<div class="flex-grow">
<div class="relative flex"> <div class="relative flex">
<img src="/assets/icons/mapEditor/search.svg" class="w-4 h-4 py-0.5 absolute top-1/2 -translate-y-1/2 left-2.5" alt="Search icon" /> <img src="/assets/icons/mapEditor/search.svg" class="w-4 h-4 py-0.5 absolute top-1/2 -translate-y-1/2 left-2.5" alt="Search icon" />
<label class="mb-1.5 font-titles hidden" for="search">Search</label> <label class="mb-1.5 font-titles hidden" for="search">Search</label>
<input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" /> <input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" />
</div> </div>
</div>
<img src="/assets/icons/mapEditor/dropdown-chevron.svg" class="w-12 h-12 ml-2 cursor-pointer hover:opacity-80 -rotate-90" alt="Close" @click="mapEditor.setTool('move')" />
</div>
<div class="flex"> <div class="flex">
<select class="input-field w-full" name="lists" v-model="mapEditor.drawMode.value" @change="(event: any) => mapEditor.setDrawMode(event.target.value)"> <select class="input-field w-full" name="lists" v-model="mapEditor.drawMode.value" @change="(event: any) => mapEditor.setDrawMode(event.target.value)">
<option value="tile">Tiles</option> <option value="tile">Tiles</option>

View File

@ -45,6 +45,7 @@ import { SocketEvent } from '@/application/enums'
import type { MapObject, Map as MapT, PlacedMapObject } from '@/application/types' import type { MapObject, Map as MapT, PlacedMapObject } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue' import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { socketManager } from '@/managers/SocketManager'
import { MapObjectStorage } from '@/storage/storages' import { MapObjectStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
@ -81,7 +82,7 @@ const handleDelete = () => {
async function handleUpdate() { async function handleUpdate() {
if (!mapObject.value) return if (!mapObject.value) return
gameStore.connection?.emit( socketManager.emit(
SocketEvent.GM_MAPOBJECT_UPDATE, SocketEvent.GM_MAPOBJECT_UPDATE,
{ {
id: props.placedMapObject.mapObject as string, id: props.placedMapObject.mapObject as string,

View File

@ -1,5 +1,5 @@
<template> <template>
<Modal ref="modalRef" @modal:close="() => mapEditorStore.setTool('move')" :modal-width="300" :modal-height="350" :is-resizable="false" bg-style="none"> <Modal v-if="showTeleportModal" ref="modalRef" @modal:close="() => mapEditor.setTool('move')" :modal-width="300" :modal-height="350" :is-resizable="false" bg-style="none">
<template #modalHeader> <template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">Teleport settings</h3> <h3 class="m-0 font-medium shrink-0 text-white">Teleport settings</h3>
</template> </template>
@ -28,7 +28,7 @@
<label for="toMap">Map to teleport to</label> <label for="toMap">Map to teleport to</label>
<select v-model="toMap" class="input-field" name="toMap" id="toMap"> <select v-model="toMap" class="input-field" name="toMap" id="toMap">
<option :value="null">Select map</option> <option :value="null">Select map</option>
<option v-for="map in mapList" :key="map.id" :value="map">{{ map.name }}</option> <option v-for="map in mapList" :key="map.id" :value="map.id">{{ map.name }}</option>
</select> </select>
</div> </div>
</div> </div>
@ -39,51 +39,50 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import type { Map } from '@/application/types' import type { Map } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue' import Modal from '@/components/utilities/Modal.vue'
import { useGameStore } from '@/stores/gameStore' import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { useMapEditorStore } from '@/stores/mapEditorStore' import { MapStorage } from '@/storage/storages'
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue' import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
const showTeleportModal = computed(() => mapEditorStore.tool === 'pencil' && mapEditorStore.drawMode === 'teleport') const showTeleportModal = computed(() => mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'teleport')
const mapEditorStore = useMapEditorStore() const mapStorage = new MapStorage()
const gameStore = useGameStore() const mapEditor = useMapEditorComposable()
const mapList = ref<Map[]>([])
const modalRef = useTemplateRef('modalRef') const modalRef = useTemplateRef('modalRef')
const mapList = ref<Map[]>([])
defineExpose({ defineExpose({
open: () => modalRef.value?.open() open: () => modalRef.value?.open()
}) })
onMounted(fetchMaps)
function fetchMaps() {
// gameStore.connection?.emit(SocketEvent.GM_MAP_LIST, {}, (response: Map[]) => {
// mapList.value = response
// })
}
const { toPositionX, toPositionY, toRotation, toMap } = useRefTeleportSettings() const { toPositionX, toPositionY, toRotation, toMap } = useRefTeleportSettings()
function useRefTeleportSettings() { function useRefTeleportSettings() {
const settings = mapEditorStore.teleportSettings const settings = mapEditor.teleportSettings.value
return { return {
toPositionX: ref(settings.toPositionX), toPositionX: ref(settings.toPositionX),
toPositionY: ref(settings.toPositionY), toPositionY: ref(settings.toPositionY),
toRotation: ref(settings.toRotation), toRotation: ref(settings.toRotation),
toMap: ref(settings.toMapId) toMap: ref(settings.toMap)
} }
} }
watch([toPositionX, toPositionY, toRotation, toMap], updateTeleportSettings) watch([toPositionX, toPositionY, toRotation, toMap], updateTeleportSettings)
function updateTeleportSettings() { function updateTeleportSettings() {
mapEditorStore.setTeleportSettings({ mapEditor.setTeleportSettings({
toPositionX: toPositionX.value, toPositionX: toPositionX.value,
toPositionY: toPositionY.value, toPositionY: toPositionY.value,
toRotation: toRotation.value, toRotation: toRotation.value,
toMapId: toMap.value toMap: toMap.value
}) })
} }
async function fetchMaps() {
mapList.value = await mapStorage.getAll()
}
onMounted(async () => {
await fetchMaps()
})
</script> </script>

View File

@ -1,11 +1,16 @@
<template> <template>
<div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="(mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'tile') || mapEditor.tool.value === 'paint'"> <div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="(mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'tile') || mapEditor.tool.value === 'paint'">
<div class="flex flex-col gap-2.5 p-2.5"> <div class="flex flex-col gap-2.5 p-2.5">
<div class="flex justify-between items-center">
<div class="flex-grow">
<div class="relative flex"> <div class="relative flex">
<img src="/assets/icons/mapEditor/search.svg" class="w-4 h-4 py-0.5 absolute top-1/2 -translate-y-1/2 left-2.5" alt="Search icon" /> <img src="/assets/icons/mapEditor/search.svg" class="w-4 h-4 py-0.5 absolute top-1/2 -translate-y-1/2 left-2.5" alt="Search icon" />
<label class="mb-1.5 font-titles hidden" for="search">Search</label> <label class="mb-1.5 font-titles hidden" for="search">Search</label>
<input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" /> <input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" />
</div> </div>
</div>
<img src="/assets/icons/mapEditor/dropdown-chevron.svg" class="w-12 h-12 ml-2 cursor-pointer hover:opacity-80 -rotate-90" alt="Close" @click="mapEditor.setTool('move')" />
</div>
<div class="flex"> <div class="flex">
<select class="input-field w-full" name="lists" v-model="mapEditor.drawMode.value" @change="(event: any) => mapEditor.setDrawMode(event.target.value)"> <select class="input-field w-full" name="lists" v-model="mapEditor.drawMode.value" @change="(event: any) => mapEditor.setDrawMode(event.target.value)">
<option value="tile">Tiles</option> <option value="tile">Tiles</option>

View File

@ -117,7 +117,7 @@ const selectPencilOpen = ref(false)
const selectEraserOpen = ref(false) const selectEraserOpen = ref(false)
const isContinuousDrawingEnabled = ref<Boolean>(false) const isContinuousDrawingEnabled = ref<Boolean>(false)
const isShowPlacedMapObjectPreviewEnabled = ref<Boolean>(mapEditor.isPlacedMapObjectPreviewEnabled.value) const isShowPlacedMapObjectPreviewEnabled = ref<Boolean>(mapEditor.isPlacedMapObjectPreviewEnabled.value)
const listOpen = computed(() => mapEditor.tool.value === 'pencil' && (mapEditor.drawMode.value === 'tile' || mapEditor.drawMode.value === 'map_object')) const listOpen = computed(() => (mapEditor.tool.value === 'pencil' && (mapEditor.drawMode.value === 'tile' || mapEditor.drawMode.value === 'map_object')) || mapEditor.tool.value === 'paint')
// drawMode // drawMode
function setDrawMode(value: string) { function setDrawMode(value: string) {
@ -148,6 +148,7 @@ function handleModeClick(mode: string, type: 'pencil' | 'eraser') {
function handleClick(tool: string) { function handleClick(tool: string) {
mapEditor.setTool(tool) mapEditor.setTool(tool)
if (tool === 'paint') mapEditor.setDrawMode('tile')
selectPencilOpen.value = tool === 'pencil' ? !selectPencilOpen.value : false selectPencilOpen.value = tool === 'pencil' ? !selectPencilOpen.value : false
selectEraserOpen.value = tool === 'eraser' ? !selectEraserOpen.value : false selectEraserOpen.value = tool === 'eraser' ? !selectEraserOpen.value : false
if (mapEditor.drawMode.value === 'teleport') emit('open-teleport') if (mapEditor.drawMode.value === 'teleport') emit('open-teleport')

View File

@ -26,6 +26,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { socketManager } from '@/managers/SocketManager'
import { login } from '@/services/authenticationService' import { login } from '@/services/authenticationService'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useCookies } from '@vueuse/integrations/useCookies' import { useCookies } from '@vueuse/integrations/useCookies'
@ -53,7 +54,7 @@ async function submit() {
formError.value = response.error formError.value = response.error
return return
} }
gameStore.setToken(response.token) socketManager.setToken(response.token)
gameStore.initConnection() gameStore.initConnection()
return true // Indicate success return true // Indicate success
} }

View File

@ -26,6 +26,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { socketManager } from '@/managers/SocketManager'
import { login, register } from '@/services/authenticationService' import { login, register } from '@/services/authenticationService'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useCookies } from '@vueuse/integrations/useCookies' import { useCookies } from '@vueuse/integrations/useCookies'
@ -67,7 +68,7 @@ async function submit() {
return return
} }
gameStore.setToken(loginResponse.token) socketManager.setToken(loginResponse.token)
gameStore.initConnection() gameStore.initConnection()
} }
</script> </script>

View File

@ -10,8 +10,9 @@
<div class="filler"></div> <div class="filler"></div>
<div class="w-2/3 max-w-[860px]" v-if="!isLoading"> <div class="w-2/3 max-w-[860px]" v-if="!isLoading">
<div class="mb-5 flex flex-col gap-1"> <div class="mb-5 flex flex-col gap-1">
<h1 class="text-white font-bold">SELECT CHARACTER TO PLAY</h1> <h1 class="text-white font-bold">{{ isCreatingCharacter ? 'CREATE CHARACTER' : 'SELECT CHARACTER TO PLAY' }}</h1>
<p class="m-0">Maximum of 4 characters can be created per player</p> <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>
<div class="flex w-full max-lg:flex-col lg:h-[400px] default-border rounded-md bg-gray"> <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"> <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,14 +21,15 @@
<img src="/assets/placeholders/head.png" class="w-9 h-9 object-contain center-element" alt="Player head" /> <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" /> <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>
<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"> <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="isCreateNewCharacterModalOpen = true"> <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" /> <img class="w-6 h-6 object-contain center-element btn-sound" draggable="false" src="/assets/icons/plus-icon.svg" alt="Add character" />
</button> </button>
</div> </div>
</div> </div>
<div class="py-6 px-8 h-[calc(100%_-_48px)] flex flex-col items-center gap-6 justify-center" v-if="selectedCharacterId"> <div class="py-6 px-8 h-[calc(100%_-_48px)] flex flex-col items-center gap-6 justify-center">
<input class="input-field w-[158px]" type="text" name="name" :placeholder="characters.find((c) => c.id == selectedCharacterId)?.name" /> <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-4 items-center">
<div class="flex flex-col gap-3"> <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="bg-[url('/assets/ui-elements/character-select-ui-shape.svg')] w-[190px] h-52 bg-no-repeat bg-center flex items-center justify-between">
@ -40,48 +42,74 @@
</button> </button>
</div> </div>
</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> </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/' + 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>
</template>
</div> </div>
</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="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"> <div class="flex flex-col gap-3 w-full">
<span class="text-sm">Hairstyle</span> <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 <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" 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" /> <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" /> <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> </div>
<!-- TODO #255: make radio button so we can set a value, do the same with swatches -->
<div <div
v-for="hair in characterHairs" 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" 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" /> <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>
</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> </div>
</div> </div>
@ -89,77 +117,102 @@
<div v-else> <div v-else>
<img class="w-20 invert-80" src="/assets/icons/loading-icon1.svg" alt="Loading" /> <img class="w-20 invert-80" src="/assets/icons/loading-icon1.svg" alt="Loading" />
</div> </div>
<div class="w-2/3 button-wrapper flex self-center justify-center lg:justify-end gap-4 max-w-[860px]" v-if="!isLoading"> <div class="w-2/3 button-wrapper flex self-center justify-center lg:justify-end gap-4 max-w-[860px]" v-if="!isLoading">
<template v-if="!isCreatingCharacter">
<button class="btn-empty min-w-48" @click.stop="gameStore.disconnectSocket()">Back</button> <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> <button class="btn-cyan min-w-48 disabled:bg-cyan-800 disabled:cursor-not-allowed" :disabled="!selectedCharacterId" @click="loginWithCharacter()">Play now</button>
</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>
<template v-else>
<template #modalBody> <button class="btn-empty min-w-48" @click="cancelCharacterCreation">Cancel</button>
<div class="p-4 h-[calc(100%_-_32px)]"> <button class="btn-cyan min-w-48" @click="createCharacter">Create</button>
<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> </template>
</Modal> </div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import { type CharacterHair, type Character as CharacterT, type Map } from '@/application/types' 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 { useSoundComposable } from '@/composables/useSoundComposable'
import { CharacterHairStorage } from '@/storage/storages' import { socketManager } from '@/managers/SocketManager'
import { CharacterHairStorage, CharacterTypeStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' 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 { playSound } = useSoundComposable()
const gameStore = useGameStore() const gameStore = useGameStore()
const isLoading = ref<boolean>(true) const isLoading = ref<boolean>(true)
const characters = ref<CharacterT[]>([]) const characters = ref<CharacterT[]>([])
const selectedCharacterId = ref<string | null>(null) const selectedCharacterId = ref<string | null>(null)
const isCreateNewCharacterModalOpen = ref<boolean>(false) const newNickname = ref<string>('')
const newCharacterName = ref<string>('') const newCharacterName = ref<string>('')
const characterHairs = ref<CharacterHair[]>([]) const characterHairs = ref<CharacterHair[]>([])
const selectedHairId = ref<string | null>(null) 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 // Fetch characters
setTimeout(() => { setTimeout(() => {
console.log(SocketEvent.CHARACTER_LIST) socketManager.emit(SocketEvent.CHARACTER_LIST)
gameStore.connection?.emit(SocketEvent.CHARACTER_LIST)
}, 750) }, 750)
gameStore.connection?.on(SocketEvent.CHARACTER_LIST, (data: any) => { socketManager.on(SocketEvent.CHARACTER_LIST, (data: any) => {
characters.value = data characters.value = data
isLoading.value = false isLoading.value = false
}) })
// Select character logics
function loginWithCharacter() { function loginWithCharacter() {
if (!selectedCharacterId.value) return if (!selectedCharacterId.value) return
gameStore.connection?.emit( socketManager.emit(
SocketEvent.CHARACTER_CONNECT, SocketEvent.CHARACTER_CONNECT,
{ {
characterId: selectedCharacterId.value, characterId: selectedCharacterId.value,
characterHairId: selectedHairId.value characterHairId: selectedHairId.value,
newNickname: newNickname.value
}, },
(response: { character: CharacterT; map: Map; characters: CharacterT[] }) => { (response: { character: CharacterT; map: Map; characters: CharacterT[] }) => {
gameStore.setCharacter(response.character) gameStore.setCharacter(response.character)
@ -169,27 +222,51 @@ function loginWithCharacter() {
// Create character logics // Create character logics
function createCharacter() { function createCharacter() {
gameStore.connection?.emit(SocketEvent.CHARACTER_CREATE, { name: newCharacterName.value }, (success: boolean) => { if (newCharacterName.value.length < characterCreationSettings.minNameLength || newCharacterName.value.length > characterCreationSettings.maxNameLength) {
if (success) return return
isCreateNewCharacterModalOpen.value = false }
})
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 changes for selected character and update hairs
watch(selectedCharacterId, (characterId) => { watch(selectedCharacterId, (characterId) => {
if (!characterId) return if (!characterId) return
newNickname.value = ''
isCreatingCharacter.value = false
selectedHairId.value = characters.value.find((c) => c.id == characterId)?.characterHair ?? null selectedHairId.value = characters.value.find((c) => c.id == characterId)?.characterHair ?? null
}) })
onMounted(async () => { onMounted(async () => {
playSound('/assets/music/intro.mp3') await playSound('/assets/music/intro.mp3')
const characterHairStorage = new CharacterHairStorage() const characterHairStorage = new CharacterHairStorage()
const characterTypeStorage = new CharacterTypeStorage()
characterHairs.value = await characterHairStorage.getAll() 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(() => { onBeforeUnmount(() => {
gameStore.connection?.off(SocketEvent.CHARACTER_LIST) socketManager.off(SocketEvent.CHARACTER_LIST)
gameStore.connection?.off(SocketEvent.CHARACTER_CONNECT) socketManager.off(SocketEvent.CHARACTER_CONNECT)
gameStore.connection?.off(SocketEvent.CHARACTER_CREATE) socketManager.off(SocketEvent.CHARACTER_CREATE)
}) })
</script> </script>

View File

@ -20,8 +20,10 @@
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import { socketManager } from '@/managers/SocketManager'
import 'phaser' import 'phaser'
import type { Map as MapT } from '@/application/types' import type { Map as MapT } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import Map from '@/components/gameMaster/mapEditor/Map.vue' import Map from '@/components/gameMaster/mapEditor/Map.vue'
import MapList from '@/components/gameMaster/mapEditor/partials/MapList.vue' import MapList from '@/components/gameMaster/mapEditor/partials/MapList.vue'
import MapObjectList from '@/components/gameMaster/mapEditor/partials/MapObjectList.vue' import MapObjectList from '@/components/gameMaster/mapEditor/partials/MapObjectList.vue'
@ -32,13 +34,10 @@ import Toolbar from '@/components/gameMaster/mapEditor/partials/Toolbar.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { loadAllTileTextures } from '@/services/mapService' import { loadAllTileTextures } from '@/services/mapService'
import { MapStorage } from '@/storage/storages' import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { Game, Scene } from 'phavuer' import { Game, Scene } from 'phavuer'
import { ref, useTemplateRef } from 'vue' import { ref, toRaw, useTemplateRef } from 'vue'
const mapStorage = new MapStorage()
const mapEditor = useMapEditorComposable() const mapEditor = useMapEditorComposable()
const gameStore = useGameStore()
const mapModal = useTemplateRef('mapModal') const mapModal = useTemplateRef('mapModal')
const mapSettingsModal = useTemplateRef('mapSettingsModal') const mapSettingsModal = useTemplateRef('mapSettingsModal')
@ -83,8 +82,8 @@ const preloadScene = async (scene: Phaser.Scene) => {
}) })
} }
function save() { async function save() {
const currentMap = mapEditor.currentMap.value const currentMap = toRaw(mapEditor.currentMap.value)
if (!currentMap) return if (!currentMap) return
const data = { const data = {
@ -92,8 +91,9 @@ function save() {
mapId: currentMap.id mapId: currentMap.id
} }
gameStore.connection?.emit(SocketEvent.GM_MAP_UPDATE, data, (response: MapT) => { socketManager.emit(SocketEvent.GM_MAP_UPDATE, data, async (response: MapT) => {
mapStorage.update(response.id, response) 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

@ -1,10 +1,14 @@
<template></template> <template></template>
<script setup lang="ts"> <script setup lang="ts">
import { socketManager } from '@/managers/SocketManager'
import { login } from '@/services/authenticationService'
import { CharacterHairStorage, CharacterTypeStorage, MapObjectStorage, MapStorage, SoundStorage, SpriteStorage, TileStorage } from '@/storage/storages' import { CharacterHairStorage, CharacterTypeStorage, MapObjectStorage, MapStorage, SoundStorage, SpriteStorage, TileStorage } from '@/storage/storages'
import { TextureStorage } from '@/storage/textureStorage' import { TextureStorage } from '@/storage/textureStorage'
import { useGameStore } from '@/stores/gameStore'
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
const gameStore = useGameStore()
const mapStorage = new MapStorage() const mapStorage = new MapStorage()
const tileStorage = new TileStorage() const tileStorage = new TileStorage()
const mapObjectStorage = new MapObjectStorage() const mapObjectStorage = new MapObjectStorage()
@ -37,6 +41,17 @@ async function handleKeyPress(event: KeyboardEvent) {
currentString = '' // Reset currentString = '' // Reset
} }
if (currentString.includes('11')) {
if (socketManager.token) return
const response = await login('root', 'password')
if (response.success === undefined) {
return
}
socketManager.setToken(response.token)
gameStore.initConnection()
}
// Reset string after a certain amount of time // Reset string after a certain amount of time
setTimeout(() => { setTimeout(() => {
currentString = '' currentString = ''

View File

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

View File

@ -1,4 +1,5 @@
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import { socketManager } from '@/managers/SocketManager'
import { getTile } from '@/services/mapService' import { getTile } from '@/services/mapService'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import type { Ref } from 'vue' import type { Ref } from 'vue'
@ -7,7 +8,77 @@ import { useBaseControlsComposable } from './useBaseControlsComposable'
export function useGameControlsComposable(scene: Phaser.Scene, layer: Phaser.Tilemaps.TilemapLayer, waypoint: Ref<{ visible: boolean; x: number; y: number }>, camera: Phaser.Cameras.Scene2D.Camera) { export function useGameControlsComposable(scene: Phaser.Scene, layer: Phaser.Tilemaps.TilemapLayer, waypoint: Ref<{ visible: boolean; x: number; y: number }>, camera: Phaser.Cameras.Scene2D.Camera) {
const gameStore = useGameStore() const gameStore = useGameStore()
const baseHandlers = useBaseControlsComposable(scene, layer, waypoint, camera) const baseHandlers = useBaseControlsComposable(scene, layer, waypoint, camera)
const pressedKeys = new Set<string>()
let moveTimeout: NodeJS.Timeout | null = null
let currentPosition = {
x: 0,
y: 0
}
// Movement constants
const MOVEMENT_DELAY = 110 // Milliseconds between moves
const ARROW_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'] as const
function updateCurrentPosition() {
if (!gameStore.character) return
currentPosition = {
x: gameStore.character.positionX,
y: gameStore.character.positionY
}
}
function calculateNewPosition() {
let newX = currentPosition.x
let newY = currentPosition.y
if (pressedKeys.has('ArrowLeft')) newX--
if (pressedKeys.has('ArrowRight')) newX++
if (pressedKeys.has('ArrowUp')) newY--
if (pressedKeys.has('ArrowDown')) newY++
return { newX, newY }
}
function emitMovement(x: number, y: number) {
if (x === currentPosition.x && y === currentPosition.y) return
socketManager.emit(SocketEvent.MAP_CHARACTER_MOVE, [x, y])
socketManager.on(SocketEvent.MAP_CHARACTER_MOVE, ([characterId, posX, posY, rot, isMoving]: [string, number, number, number, boolean]) => {
if (characterId !== gameStore.character?.id) return
currentPosition = { x: posX, y: posY }
})
currentPosition = { x, y }
}
function startMovementLoop() {
if (moveTimeout) return
const move = () => {
if (pressedKeys.size === 0) {
stopMovementLoop()
return
}
updateCurrentPosition()
const { newX, newY } = calculateNewPosition()
emitMovement(newX, newY)
moveTimeout = setTimeout(move, MOVEMENT_DELAY)
}
move()
}
function stopMovementLoop() {
if (moveTimeout) {
clearTimeout(moveTimeout)
moveTimeout = null
}
}
// Pointer Handlers
function handlePointerDown(pointer: Phaser.Input.Pointer) { function handlePointerDown(pointer: Phaser.Input.Pointer) {
baseHandlers.startDragging(pointer) baseHandlers.startDragging(pointer)
} }
@ -23,70 +94,37 @@ export function useGameControlsComposable(scene: Phaser.Scene, layer: Phaser.Til
const pointerTile = getTile(layer, pointer.worldX, pointer.worldY) const pointerTile = getTile(layer, pointer.worldX, pointer.worldY)
if (!pointerTile) return if (!pointerTile) return
gameStore.connection?.emit(SocketEvent.MAP_CHARACTER_MOVE, { emitMovement(pointerTile.x, pointerTile.y)
positionX: pointerTile.x,
positionY: pointerTile.y
})
} }
const pressedKeys = new Set<string>() // Keyboard Handlers
let moveInterval: number | null = null
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (!gameStore.character) return if (!gameStore.character) return
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) { if (ARROW_KEYS.includes(event.key as (typeof ARROW_KEYS)[number])) {
// Prevent key repeat events
if (event.repeat) return if (event.repeat) return
pressedKeys.add(event.key) pressedKeys.add(event.key)
updateCurrentPosition()
// Start movement loop if not already running startMovementLoop()
if (!moveInterval) {
moveInterval = window.setInterval(moveCharacter, 80) // Increased interval to match server throttle `MOVEMENT_THROTTLE`
moveCharacter() // Move immediately on first press
}
} }
// Attack on CTRL
if (event.key === 'Control') { if (event.key === 'Control') {
gameStore.connection?.emit(SocketEvent.MAP_CHARACTER_ATTACK) socketManager.emit(SocketEvent.MAP_CHARACTER_ATTACK)
} }
} }
function handleKeyUp(event: KeyboardEvent) { function handleKeyUp(event: KeyboardEvent) {
pressedKeys.delete(event.key) pressedKeys.delete(event.key)
// If no movement keys are pressed, clear the interval if (pressedKeys.size === 0) {
if (pressedKeys.size === 0 && moveInterval) { stopMovementLoop()
clearInterval(moveInterval)
moveInterval = null
}
}
function moveCharacter() {
if (!gameStore.character) return
const { positionX, positionY } = gameStore.character
let newX = positionX
let newY = positionY
// Calculate new position based on pressed keys
if (pressedKeys.has('ArrowLeft')) newX--
if (pressedKeys.has('ArrowRight')) newX++
if (pressedKeys.has('ArrowUp')) newY--
if (pressedKeys.has('ArrowDown')) newY++
// Only emit if position changed
if (newX !== positionX || newY !== positionY) {
gameStore.connection?.emit(SocketEvent.MAP_CHARACTER_MOVE, {
positionX: newX,
positionY: newY
})
} }
} }
const setupControls = () => { const setupControls = () => {
updateCurrentPosition() // Initialize position
scene.input.on(Phaser.Input.Events.POINTER_DOWN, handlePointerDown) scene.input.on(Phaser.Input.Events.POINTER_DOWN, handlePointerDown)
scene.input.on(Phaser.Input.Events.POINTER_MOVE, handlePointerMove) scene.input.on(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
scene.input.on(Phaser.Input.Events.POINTER_UP, handlePointerUp) scene.input.on(Phaser.Input.Events.POINTER_UP, handlePointerUp)
@ -96,6 +134,9 @@ export function useGameControlsComposable(scene: Phaser.Scene, layer: Phaser.Til
} }
const cleanupControls = () => { const cleanupControls = () => {
stopMovementLoop()
pressedKeys.clear()
scene.input.off(Phaser.Input.Events.POINTER_DOWN, handlePointerDown) scene.input.off(Phaser.Input.Events.POINTER_DOWN, handlePointerDown)
scene.input.off(Phaser.Input.Events.POINTER_MOVE, handlePointerMove) scene.input.off(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
scene.input.off(Phaser.Input.Events.POINTER_UP, handlePointerUp) scene.input.off(Phaser.Input.Events.POINTER_UP, handlePointerUp)

View File

@ -1,9 +1,9 @@
import config from '@/application/config' import config from '@/application/config'
import { Direction } from '@/application/enums' 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 { calculateIsometricDepth, tileToWorldX, tileToWorldY } from '@/services/mapService'
import { loadSpriteTextures } from '@/services/textureService' import { loadSpriteTextures } from '@/services/textureService'
import { CharacterTypeStorage } from '@/storage/storages' import { CharacterTypeStorage, SpriteStorage } from '@/storage/storages'
import { refObj } from 'phavuer' import { refObj } from 'phavuer'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@ -18,7 +18,7 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
const tween = ref<Phaser.Tweens.Tween | null>(null) const tween = ref<Phaser.Tweens.Tween | null>(null)
const updateIsometricDepth = (positionX: number, positionY: number) => { const updateIsometricDepth = (positionX: number, positionY: number) => {
isometricDepth.value = calculateIsometricDepth(positionX, positionY, 30, 95) isometricDepth.value = calculateIsometricDepth(positionX, positionY)
} }
const updatePosition = (positionX: number, positionY: number) => { const updatePosition = (positionX: number, positionY: number) => {
@ -72,7 +72,7 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
// Add new listener // Add new listener
characterSprite.value.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => { characterSprite.value.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
characterSprite.value!.setFrame(0) characterSprite.value!.setFrame(0)
characterSprite.value!.setTexture(charTexture.value) characterSprite.value!.setTexture(spriteSpriteActionId.value)
}) })
characterSprite.value.anims.play( 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' 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(() => { const currentAction = computed(() => {
return mapCharacter.isMoving ? 'walk' : 'idle' return mapCharacter.isMoving ? 'walk' : 'idle'
}) })
const charTexture = computed(() => { const spriteSpriteActionId = computed(() => {
const spriteId = characterSpriteId.value ?? 'idle_right_down' const spriteId = characterSpriteId.value ?? 'idle_right_down'
return `${spriteId}-${currentAction.value}_${currentDirection.value}` return `${spriteId}-${currentAction.value}_${currentDirection.value}`
}) })
@ -109,11 +121,11 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
if (!characterSprite.value) return if (!characterSprite.value) return
if (mapCharacter.isMoving) { if (mapCharacter.isMoving) {
characterSprite.value.anims.play(charTexture.value, true) characterSprite.value.anims.play(spriteSpriteActionId.value, true)
} else { } else {
characterSprite.value.anims.stop() characterSprite.value.anims.stop()
characterSprite.value.setFrame(0) 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) { if (characterSprite.value) {
characterSprite.value.setTexture(charTexture.value) characterSprite!.value?.setOrigin(0.5, 1)
characterSprite.value.setTexture(spriteSpriteActionId.value)
characterSprite.value.setFlipX(isFlippedX.value) characterSprite.value.setFlipX(isFlippedX.value)
} }
@ -146,10 +159,15 @@ export function useCharacterSpriteComposable(scene: Phaser.Scene, tilemap: Phase
characterContainer, characterContainer,
characterSpriteId, characterSpriteId,
characterSprite, characterSprite,
spriteHeight,
currentAction,
spriteSpriteActionId,
currentPositionX, currentPositionX,
currentPositionY, currentPositionY,
currentDirection,
isometricDepth, isometricDepth,
isFlippedX, isFlippedX,
getSpriteHeightByAction,
updatePosition, updatePosition,
playAnimation, playAnimation,
calcDirection, calcDirection,

View File

@ -1,8 +1,9 @@
import type { Map, MapObject, PlacedMapObject, UUID } from '@/application/types' import type { Map, MapObject, PlacedMapObject, UUID } from '@/application/types'
import { useGameStore } from '@/stores/gameStore'
import { ref } from 'vue' import { ref } from 'vue'
export type TeleportSettings = { export type TeleportSettings = {
toMapId: string toMap: string
toPositionX: number toPositionX: number
toPositionY: number toPositionY: number
toRotation: number toRotation: number
@ -20,7 +21,7 @@ const movingPlacedObject = ref<PlacedMapObject | null>(null)
const selectedPlacedObject = ref<PlacedMapObject | null>(null) const selectedPlacedObject = ref<PlacedMapObject | null>(null)
const shouldClearTiles = ref(false) const shouldClearTiles = ref(false)
const teleportSettings = ref<TeleportSettings>({ const teleportSettings = ref<TeleportSettings>({
toMapId: '1000', toMap: '1000',
toPositionX: 0, toPositionX: 0,
toPositionY: 0, toPositionY: 0,
toRotation: 0 toRotation: 0
@ -46,6 +47,8 @@ export function useMapEditorComposable() {
const toggleActive = () => { const toggleActive = () => {
if (active.value) reset() if (active.value) reset()
active.value = !active.value active.value = !active.value
const gameStore = useGameStore()
gameStore.uiSettings.isGmPanelOpen = false
} }
const togglePlacedMapObjectPreview = () => { const togglePlacedMapObjectPreview = () => {
@ -95,6 +98,7 @@ export function useMapEditorComposable() {
selectedTile.value = '' selectedTile.value = ''
isPlacedMapObjectPreviewEnabled.value = false isPlacedMapObjectPreviewEnabled.value = false
selectedMapObject.value = null selectedMapObject.value = null
selectedPlacedObject.value = null
shouldClearTiles.value = false shouldClearTiles.value = false
refreshMapObject.value = 0 refreshMapObject.value = 0
} }

View File

@ -0,0 +1,76 @@
import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import { useCookies } from '@vueuse/integrations/useCookies'
import { io, Socket } from 'socket.io-client'
import { ref, shallowRef } from 'vue'
class SocketManager {
private static instance: SocketManager
private _connection = shallowRef<Socket | null>(null)
private _token = ref('')
private constructor() {}
public static getInstance(): SocketManager {
if (!SocketManager.instance) {
SocketManager.instance = new SocketManager()
}
return SocketManager.instance
}
public get connection() {
return this._connection.value
}
public get token() {
return this._token.value
}
public setToken(token: string) {
this._token.value = token
}
public initConnection(): Socket {
if (this._connection.value) return this._connection.value
const socket = io(config.server_endpoint, {
secure: config.environment === 'production',
withCredentials: true,
transports: ['websocket'],
reconnectionAttempts: 5
})
this._connection.value = socket
return socket
}
public disconnect(): void {
if (!this._connection.value) return
this._connection.value.off(SocketEvent.CONNECT_ERROR)
this._connection.value.off(SocketEvent.RECONNECT_FAILED)
this._connection.value.off(SocketEvent.DATE)
this._connection.value.disconnect()
useCookies().remove('token', {
domain: config.domain
})
this._connection.value = null
this._token.value = ''
}
public emit(event: string, ...args: any[]): void {
this._connection.value?.emit(event, ...args)
}
public on(event: string, callback: (...args: any[]) => void): void {
this._connection.value?.on(event, callback)
}
public off(event: string, callback?: (...args: any[]) => void): void {
this._connection.value?.off(event, callback)
}
}
export const socketManager = SocketManager.getInstance()

View File

@ -62,12 +62,8 @@ export function createTileArray(width: number, height: number, tile: string = 'b
return Array.from({ length: height }, () => Array.from({ length: width }, () => tile)) return Array.from({ length: height }, () => Array.from({ length: width }, () => tile))
} }
export const calculateIsometricDepth = (positionX: number, positionY: number, width: number = 0, height: number = 0, isCharacter: boolean = false) => { export const calculateIsometricDepth = (positionX: number, positionY: number) => {
const baseDepth = positionX + positionY return positionX + positionY
if (isCharacter) {
return baseDepth
}
return baseDepth + (width + height) / (2 * config.tile_size.width)
} }
async function loadTileTextures(tiles: TileT[], scene: Phaser.Scene) { async function loadTileTextures(tiles: TileT[], scene: Phaser.Scene) {

View File

@ -1,16 +1,12 @@
import config from '@/application/config'
import { SocketEvent } from '@/application/enums' import { SocketEvent } from '@/application/enums'
import type { Character, Notification, User, WorldSettings } from '@/application/types' import type { Character, Notification, User, WorldSettings } from '@/application/types'
import { useCookies } from '@vueuse/integrations/useCookies' import { socketManager } from '@/managers/SocketManager'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { io, Socket } from 'socket.io-client'
export const useGameStore = defineStore('game', { export const useGameStore = defineStore('game', {
state: () => { state: () => {
return { return {
notifications: [] as Notification[], notifications: [] as Notification[],
token: '',
connection: null as Socket | null,
user: null as User | null, user: null as User | null,
character: null as Character | null, character: null as Character | null,
world: { world: {
@ -51,9 +47,6 @@ export const useGameStore = defineStore('game', {
removeNotification(id: string) { removeNotification(id: string) {
this.notifications = this.notifications.filter((notification: Notification) => notification.id !== id) this.notifications = this.notifications.filter((notification: Notification) => notification.id !== id)
}, },
setToken(token: string) {
this.token = token
},
setUser(user: User | null) { setUser(user: User | null) {
this.user = user this.user = user
}, },
@ -73,49 +66,34 @@ export const useGameStore = defineStore('game', {
this.uiSettings.isCharacterProfileOpen = !this.uiSettings.isCharacterProfileOpen this.uiSettings.isCharacterProfileOpen = !this.uiSettings.isCharacterProfileOpen
}, },
initConnection() { initConnection() {
this.connection = io(config.server_endpoint, { const socket = socketManager.initConnection()
secure: config.environment === 'production',
withCredentials: true,
transports: ['websocket'],
reconnectionAttempts: 5
})
// #99 - If we can't connect, disconnect // Handle connect error
this.connection.on('connect_error', () => { socket.on(SocketEvent.CONNECT_ERROR, () => {
this.disconnectSocket() this.disconnectSocket()
}) })
// Let the server know the user is logged in // Handle failed reconnection
this.connection.emit(SocketEvent.LOGIN) socket.on(SocketEvent.RECONNECT_FAILED, () => {
this.disconnectSocket()
})
// set user // Emit login event
this.connection.on(SocketEvent.LOGGED_IN, (user: User) => { socketManager.emit(SocketEvent.LOGIN)
// Handle logged in event
socketManager.on(SocketEvent.LOGGED_IN, (user: User) => {
this.setUser(user) this.setUser(user)
}) })
// When we can't reconnect, disconnect // Handle date updates
this.connection.on('reconnect_failed', () => { socketManager.on(SocketEvent.DATE, (data: Date) => {
this.disconnectSocket()
})
// Listen for new date from socket
this.connection.on(SocketEvent.DATE, (data: Date) => {
this.world.date = new Date(data) this.world.date = new Date(data)
}) })
}, },
disconnectSocket() { disconnectSocket() {
// Remove event listeners socketManager.disconnect()
this.connection?.off('connect_error')
this.connection?.off('reconnect_failed')
this.connection?.off(SocketEvent.DATE)
this.connection?.disconnect()
useCookies().remove('token', {
domain: config.domain
})
this.connection = null
this.token = ''
this.user = null this.user = null
this.character = null this.character = null

View File

@ -2,7 +2,7 @@ import type { MapObject, Map as MapT } from '@/application/types'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
export type TeleportSettings = { export type TeleportSettings = {
toMapId: string toMap: string
toPositionX: number toPositionX: number
toPositionY: number toPositionY: number
toRotation: number toRotation: number
@ -18,7 +18,7 @@ export const useMapEditorStore = defineStore('mapEditor', {
selectedMapObject: null as MapObject | null, selectedMapObject: null as MapObject | null,
shouldClearTiles: false, shouldClearTiles: false,
teleportSettings: { teleportSettings: {
toMapId: '', toMap: '',
toPositionX: 0, toPositionX: 0,
toPositionY: 0, toPositionY: 0,
toRotation: 0 toRotation: 0

View File

@ -35,13 +35,13 @@ export const useMapStore = defineStore('map', {
removeCharacter(characterId: UUID) { removeCharacter(characterId: UUID) {
this.characters = this.characters.filter((char) => char.character.id !== characterId) this.characters = this.characters.filter((char) => char.character.id !== characterId)
}, },
updateCharacterPosition(data: { characterId: UUID; positionX: number; positionY: number; rotation: number; isMoving: boolean }) { updateCharacterPosition([characterId, posX, posY, rot, isMoving]: [UUID, number, number, number, boolean]) {
const character = this.characters.find((char) => char.character.id === data.characterId) const character = this.characters.find((char) => char.character.id === characterId)
if (character) { if (character) {
character.character.positionX = data.positionX character.character.positionX = posX
character.character.positionY = data.positionY character.character.positionY = posY
character.character.rotation = data.rotation character.character.rotation = rot
character.isMoving = data.isMoving character.isMoving = isMoving
} }
}, },
reset() { reset() {