Compare commits
348 Commits
feature/#2
...
feature/ma
Author | SHA1 | Date | |
---|---|---|---|
8bf67ab168 | |||
f83e2bf8c8 | |||
8b0bf6534e | |||
5e243e5201 | |||
c82db9813e | |||
579749f4e0 | |||
ddc26a021b | |||
2d6b1ff1e0 | |||
16720777c9 | |||
41e7832cbe | |||
e6412d8a65 | |||
faa8e5def9 | |||
beed1d6903 | |||
2ebcc24390 | |||
2e3ff803f6 | |||
dd1cc795de | |||
59243e0e17 | |||
87ffc98cce | |||
0c450b24ed | |||
9459639497 | |||
5f2c7a09b1 | |||
13e8c1b4dd | |||
b27a2e8779 | |||
b3c9e3ca3d | |||
31a91c3f9f | |||
5d4de60f90 | |||
4070bcf048 | |||
04203cb9c1 | |||
592d1df9bf | |||
9413fdbb2f | |||
34caac562c | |||
52dafb8643 | |||
390187f353 | |||
cbd111a05b | |||
5ef11f3157 | |||
c56c2796c4 | |||
c228af7bb6 | |||
f45a51c230 | |||
790a62c600 | |||
82a854e647 | |||
3bcb16fa9c | |||
f79ebedc62 | |||
44b0368276 | |||
b8b985470f | |||
39e00c6feb | |||
6de0bb200d | |||
2a00e206eb | |||
8f9b19ba8b | |||
d997a33b86 | |||
9749b02ccf | |||
f83d5eabee | |||
a9cedba4e0 | |||
49dcd92a9e | |||
d010159989 | |||
275dd95c69 | |||
e3c3d4d420 | |||
87e7f14469 | |||
723aa59142 | |||
c369719564 | |||
2d8c421ac6 | |||
1137c95ff3 | |||
4b56da0fa0 | |||
c21e78c2ec | |||
fcf96a25ae | |||
cf9deebc94 | |||
ca307d4de3 | |||
4c4e8ffe02 | |||
369522fda3 | |||
dc7e20842a | |||
75c9d5f349 | |||
b35794d6d3 | |||
6ba4c1b843 | |||
6a52546a08 | |||
8133bd02df | |||
e720a1098e | |||
48d1d920be | |||
7542fd70ed | |||
9f866fea72 | |||
ec6f3031b8 | |||
838610d041 | |||
fb3a59aa59 | |||
ccb64fc048 | |||
db52bcfff3 | |||
12735756d7 | |||
6383320e8c | |||
557b8aaabb | |||
c09e9ea841 | |||
c2d41a63a7 | |||
122a178feb | |||
909dbf4280 | |||
8add054f63 | |||
04d55f994e | |||
b83c340385 | |||
d5984f1c3f | |||
7071d934b4 | |||
15b212160d | |||
2a2841cf16 | |||
a545018639 | |||
90f3056e08 | |||
7730fd81bd | |||
b195f1399f | |||
3c06f7db97 | |||
6c7864b4d4 | |||
0c9a41c286 | |||
dffdd0542f | |||
d2abf8fda8 | |||
fdbc101f96 | |||
7ff1de4018 | |||
f258c65403 | |||
bab13646ed | |||
adc3eba237 | |||
2097a51f07 | |||
50daf01a01 | |||
14474f7665 | |||
f14d9baaa1 | |||
d2b6d8dcb3 | |||
027fdd7dac | |||
2b40741ca7 | |||
aee18956f3 | |||
cf54ab842a | |||
d25100c810 | |||
cd1daf9345 | |||
0ecd951710 | |||
ff9dcb91b0 | |||
841ec0f3df | |||
90d7252784 | |||
554497ecbc | |||
efeae337ab | |||
ad47b37279 | |||
5e11b67774 | |||
7daefb74eb | |||
4adcf8d61d | |||
e53e154d16 | |||
d65ceba66a | |||
db426bb03e | |||
af26ca5e89 | |||
e4b9bb4d61 | |||
d7f60d7bfc | |||
cfdfa98379 | |||
63889a537a | |||
99bb1555a0 | |||
ac1396304f | |||
09ee9bf01d | |||
09b458eeef | |||
9d95562679 | |||
a9de031673 | |||
8e81ce716b | |||
2c1db56cc4 | |||
4fba3678d6 | |||
d29ca10ba9 | |||
67f83c3447 | |||
8f82bad3fa | |||
d665ac989c | |||
e389534e30 | |||
7d3946e274 | |||
0f46e3b6d2 | |||
6ca82733eb | |||
eb61f45535 | |||
a181fc7fe3 | |||
507d4226ac | |||
5dd9d1e7af | |||
15f9e9861e | |||
7fd334d414 | |||
c7d4b5f2c3 | |||
5747166822 | |||
c010373e5b | |||
57ad9d4889 | |||
f268ac9e5b | |||
fb6e2aa742 | |||
8befce7ffb | |||
e530f69311 | |||
144a513cb6 | |||
2a6321b06b | |||
ba90982e35 | |||
014c08b17a | |||
bdbda6456c | |||
85537840ab | |||
2b7082ac92 | |||
abc58bfa38 | |||
027325f2bf | |||
517e92b07b | |||
6bede8c44e | |||
9e652868ca | |||
35f0dcca64 | |||
9618e07bc6 | |||
791830fd6f | |||
37acf1782b | |||
14aa696197 | |||
cfac1d508b | |||
82cfe5902f | |||
284ca6f64e | |||
967cb1893d | |||
18db005bc1 | |||
c8473fc206 | |||
b5e84c133a | |||
3f75e4acd8 | |||
765d0986bf | |||
95c3a1af61 | |||
45a9d8cfdb | |||
e0a48a089a | |||
69f9944dc7 | |||
9cdfcbcc56 | |||
a614ee6241 | |||
7a51323682 | |||
807bc2066e | |||
fab0b08425 | |||
b074270c75 | |||
30845b80e9 | |||
bde0f74f19 | |||
bc685c63ef | |||
7a922261e3 | |||
3936676f2c | |||
9744083dea | |||
176f31d84a | |||
faad00b2a5 | |||
a61e05592d | |||
5202251ac7 | |||
5e2781b265 | |||
ebd6d96e54 | |||
41005735f9 | |||
78f1c6e6a0 | |||
2d48f83802 | |||
7dccb73698 | |||
7171112881 | |||
9a601b7e2e | |||
5c68b02fff | |||
b86d9dd4ce | |||
93baa10acf | |||
419cf319be | |||
1cd7f28402 | |||
0657dbcb1b | |||
5cf7423a5c | |||
4d88917526 | |||
8f07cf5093 | |||
367d536c52 | |||
3f8c911e9d | |||
689e443b3d | |||
4fead371d7 | |||
b9bcfc719f | |||
9de7af961e | |||
4067ec2585 | |||
fb18841c91 | |||
7b1dcf7ce3 | |||
7546116878 | |||
03fef60621 | |||
574777da80 | |||
2b84bfcad2 | |||
f829cfb883 | |||
c2db9b5469 | |||
6e30a8530a | |||
41f82897a8 | |||
37b97b0aac | |||
c1d9cc3a11 | |||
b54b825422 | |||
0142850983 | |||
2d09715dc4 | |||
ef807982a5 | |||
ae0841889b | |||
bdd2f93175 | |||
10f6dc3802 | |||
700bd57e67 | |||
145143cdc5 | |||
201853a3ec | |||
40c87f0ee3 | |||
736ddddc54 | |||
63758e67b3 | |||
b51aa29bd8 | |||
f9bfbdf735 | |||
2abce7a7e7 | |||
6ec9f8a7bc | |||
8191a039c9 | |||
540425ca44 | |||
1c2e642fe3 | |||
8355c83dc8 | |||
5fcb336835 | |||
90bdf43b64 | |||
e9dfcf7870 | |||
d0c08c25fd | |||
7bb7af9476 | |||
e4186a1bf5 | |||
8c664d7774 | |||
744df2e2dc | |||
b4f9b11143 | |||
18b07d2f46 | |||
9d0f810ab3 | |||
cf3f17dfef | |||
6be1134c8c | |||
6dad7bc9dd | |||
231f19a30f | |||
9c105d6df6 | |||
179ceb0ca0 | |||
680661f07c | |||
c54d2a2da8 | |||
85f0fca2ae | |||
420e63b724 | |||
5d9b4fd19a | |||
b3d68ef562 | |||
baae737d6b | |||
03f8b327c5 | |||
b9a1ce5ab5 | |||
1b650bd733 | |||
b867250580 | |||
2c7a1e27be | |||
0e455f8ffc | |||
8005bc1318 | |||
11e978121f | |||
727ca99b73 | |||
97080d7380 | |||
1a3a53a229 | |||
a926de8466 | |||
5eabb39ec8 | |||
03313cb092 | |||
d58cfa668d | |||
e3e40dd083 | |||
facdd2d1b4 | |||
7d6bd39f29 | |||
608932300f | |||
b5c1c92b04 | |||
c68b129da8 | |||
963c593a1f | |||
a299e22f88 | |||
2007bfd7c5 | |||
4cae045d0d | |||
1fa8b8f06e | |||
4095184b27 | |||
857d56a878 | |||
8087f754b0 | |||
1479d96162 | |||
606e220a9f | |||
6988565484 | |||
fbc4a3dcdb | |||
924d5bdd13 | |||
25a2fd24f3 | |||
64f5ac45dd | |||
937ce939d1 | |||
7d89364104 | |||
f7b8c235d8 | |||
89d83efca4 | |||
ab97e27f27 | |||
ee3e1b55cb | |||
5e109e2a39 | |||
a8e50c993a | |||
ba8af589a7 | |||
301340327a | |||
1e4c58c79e | |||
f87cd063ee | |||
9593298389 | |||
ad4651844d |
@ -1,5 +1,6 @@
|
|||||||
VITE_NAME=Sylvan Quest
|
VITE_NAME=Noxious
|
||||||
VITE_DEVELOPMENT=true
|
VITE_DOMAIN=localhost
|
||||||
|
VITE_ENVIRONMENT=development
|
||||||
VITE_SERVER_ENDPOINT=http://localhost:4000
|
VITE_SERVER_ENDPOINT=http://localhost:4000
|
||||||
VITE_TILE_SIZE_X=64
|
VITE_TILE_SIZE_WIDTH=64
|
||||||
VITE_TILE_SIZE_Y=32
|
VITE_TILE_SIZE_HEIGHT=32
|
@ -1,13 +0,0 @@
|
|||||||
/* eslint-env node */
|
|
||||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-typescript', '@vue/eslint-config-prettier/skip-formatting'],
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 'latest'
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'vue/multi-word-component-names': 'off'
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,5 +4,8 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 300,
|
"printWidth": 300,
|
||||||
"trailingComma": "none"
|
"trailingComma": "none",
|
||||||
|
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||||
|
"importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy", "classProperties"],
|
||||||
|
"importOrderCaseSensitive": false
|
||||||
}
|
}
|
1
.vscode/extensions.json
vendored
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"Vue.volar",
|
"Vue.volar",
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"esbenp.prettier-vscode"
|
"esbenp.prettier-vscode"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
70
Caddyfile
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
# Global options
|
||||||
|
admin off # Disable admin API
|
||||||
|
|
||||||
|
# Global logging configuration
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log
|
||||||
|
format json
|
||||||
|
level INFO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
noxious.gg {
|
||||||
|
# Root directory for your Vue app
|
||||||
|
root * ./dist
|
||||||
|
|
||||||
|
# Enable compression with optimal settings
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Handle SPA routing
|
||||||
|
try_files {path} /index.html
|
||||||
|
|
||||||
|
# Serve static files with optimizations
|
||||||
|
file_server
|
||||||
|
|
||||||
|
# Enhanced security headers
|
||||||
|
header {
|
||||||
|
# Existing headers with improvements
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
# Additional security headers
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
|
|
||||||
|
# Remove server information
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Improved cache configuration for static assets
|
||||||
|
@static {
|
||||||
|
file
|
||||||
|
path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2 *.ttf *.eot
|
||||||
|
}
|
||||||
|
header @static {
|
||||||
|
Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
Vary Accept-Encoding
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache control for HTML files
|
||||||
|
@html {
|
||||||
|
file
|
||||||
|
path *.html
|
||||||
|
}
|
||||||
|
header @html {
|
||||||
|
Cache-Control "no-cache, must-revalidate"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle errors
|
||||||
|
handle_errors {
|
||||||
|
respond "{http.error.status_code} {http.error.status_text}" {http.error.status_code}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Improved redirect configuration
|
||||||
|
www.noxious.gg {
|
||||||
|
redir https://noxious.gg{uri} permanent
|
||||||
|
}
|
32
Dockerfile
@ -1,32 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:22.4.1-alpine as builder
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ARG VITE_NAME=${VITE_NAME}
|
|
||||||
ENV VITE_NAME=${VITE_NAME}
|
|
||||||
|
|
||||||
ARG VITE_DEVELOPMENT=${VITE_DEVELOPMENT}
|
|
||||||
ENV VITE_DEVELOPMENT=${VITE_DEVELOPMENT}
|
|
||||||
|
|
||||||
ARG VITE_SERVER_ENDPOINT=${VITE_SERVER_ENDPOINT}
|
|
||||||
ENV VITE_SERVER_ENDPOINT=${VITE_SERVER_ENDPOINT}
|
|
||||||
|
|
||||||
ARG VITE_TILE_SIZE_X=${VITE_TILE_SIZE_X}
|
|
||||||
ENV VITE_TILE_SIZE_X=${VITE_TILE_SIZE_X}
|
|
||||||
|
|
||||||
ARG VITE_TILE_SIZE_Y=${VITE_TILE_SIZE_Y}
|
|
||||||
ENV VITE_TILE_SIZE_Y=${VITE_TILE_SIZE_Y}
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build-ntc
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM nginx:1.26.1-alpine
|
|
||||||
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"schemaVersion": 2,
|
|
||||||
"dockerfilePath" :"./Dockerfile"
|
|
||||||
}
|
|
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>Sylvan Quest - Play</title>
|
<title>Noxious - Play</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
16
nginx.conf
@ -1,16 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Redirect example
|
|
||||||
location /discord {
|
|
||||||
return 301 https://discord.gg/JTev3nzeDa;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
4484
package-lock.json
generated
13
package.json
@ -11,7 +11,6 @@
|
|||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --build --force",
|
"type-check": "vue-tsc --build --force",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -19,27 +18,24 @@
|
|||||||
"@vueuse/integrations": "^10.5.0",
|
"@vueuse/integrations": "^10.5.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"dexie": "^4.0.8",
|
"dexie": "^4.0.8",
|
||||||
"phaser": "^3.86.0",
|
"phaser": "3.87.0",
|
||||||
"pinia": "^2.1.6",
|
"pinia": "^2.1.6",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"universal-cookie": "^6.1.3",
|
"universal-cookie": "^6.1.3",
|
||||||
|
"vite-plugin-image-optimizer": "^1.1.8",
|
||||||
"vue": "^3.5.12",
|
"vue": "^3.5.12",
|
||||||
"zod": "^3.22.2"
|
"zod": "^3.22.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.10.3",
|
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
|
||||||
"@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",
|
||||||
"@vitejs/plugin-vue": "^5.0.5",
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
"@vue/eslint-config-prettier": "^9.0.0",
|
|
||||||
"@vue/eslint-config-typescript": "^13.0.0",
|
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"easystarjs": "^0.4.4",
|
|
||||||
"eslint": "^8.57.0",
|
|
||||||
"eslint-plugin-vue": "^9.27.0",
|
|
||||||
"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",
|
"phaser3-rex-plugins": "^1.80.8",
|
||||||
@ -51,7 +47,6 @@
|
|||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^5.4.9",
|
"vite": "^5.4.9",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-vue-devtools": "^7.5.2",
|
|
||||||
"vitest": "^2.0.3",
|
"vitest": "^2.0.3",
|
||||||
"vue-tsc": "^1.6.5"
|
"vue-tsc": "^1.6.5"
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 9.8 KiB |
Before Width: | Height: | Size: 6.1 KiB |
Before Width: | Height: | Size: 162 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 325 B After Width: | Height: | Size: 325 B |
4
public/assets/icons/mapEditor/dropdown-chevron.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17 9.5L12 14.5L7 9.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 325 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 847 B After Width: | Height: | Size: 847 B |
Before Width: | Height: | Size: 745 B After Width: | Height: | Size: 745 B |
3
public/assets/icons/mapEditor/search.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.3333 17.5L11.0833 12.25C10.6667 12.5833 10.1875 12.8472 9.64583 13.0417C9.10417 13.2361 8.52778 13.3333 7.91667 13.3333C6.40278 13.3333 5.12153 12.809 4.07292 11.7604C3.02431 10.7118 2.5 9.43056 2.5 7.91667C2.5 6.40278 3.02431 5.12153 4.07292 4.07292C5.12153 3.02431 6.40278 2.5 7.91667 2.5C9.43056 2.5 10.7118 3.02431 11.7604 4.07292C12.809 5.12153 13.3333 6.40278 13.3333 7.91667C13.3333 8.52778 13.2361 9.10417 13.0417 9.64583C12.8472 10.1875 12.5833 10.6667 12.25 11.0833L17.5 16.3333L16.3333 17.5ZM7.91667 11.6667C8.95833 11.6667 9.84375 11.3021 10.5729 10.5729C11.3021 9.84375 11.6667 8.95833 11.6667 7.91667C11.6667 6.875 11.3021 5.98958 10.5729 5.26042C9.84375 4.53125 8.95833 4.16667 7.91667 4.16667C6.875 4.16667 5.98958 4.53125 5.26042 5.26042C4.53125 5.98958 4.16667 6.875 4.16667 7.91667C4.16667 8.95833 4.53125 9.84375 5.26042 10.5729C5.98958 11.3021 6.875 11.6667 7.91667 11.6667Z" fill="#808080"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.0 KiB |
59
public/assets/icons/mapEditor/settings.svg
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M490.667,405.333h-56.811C424.619,374.592,396.373,352,362.667,352s-61.931,22.592-71.189,53.333H21.333
|
||||||
|
C9.557,405.333,0,414.891,0,426.667S9.557,448,21.333,448h270.144c9.237,30.741,37.483,53.333,71.189,53.333
|
||||||
|
s61.931-22.592,71.189-53.333h56.811c11.797,0,21.333-9.557,21.333-21.333S502.464,405.333,490.667,405.333z M362.667,458.667
|
||||||
|
c-17.643,0-32-14.357-32-32s14.357-32,32-32s32,14.357,32,32S380.309,458.667,362.667,458.667z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M490.667,64h-56.811c-9.259-30.741-37.483-53.333-71.189-53.333S300.736,33.259,291.477,64H21.333
|
||||||
|
C9.557,64,0,73.557,0,85.333s9.557,21.333,21.333,21.333h270.144C300.736,137.408,328.96,160,362.667,160
|
||||||
|
s61.931-22.592,71.189-53.333h56.811c11.797,0,21.333-9.557,21.333-21.333S502.464,64,490.667,64z M362.667,117.333
|
||||||
|
c-17.643,0-32-14.357-32-32c0-17.643,14.357-32,32-32s32,14.357,32,32C394.667,102.976,380.309,117.333,362.667,117.333z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M490.667,234.667H220.523c-9.259-30.741-37.483-53.333-71.189-53.333s-61.931,22.592-71.189,53.333H21.333
|
||||||
|
C9.557,234.667,0,244.224,0,256c0,11.776,9.557,21.333,21.333,21.333h56.811c9.259,30.741,37.483,53.333,71.189,53.333
|
||||||
|
s61.931-22.592,71.189-53.333h270.144c11.797,0,21.333-9.557,21.333-21.333C512,244.224,502.464,234.667,490.667,234.667z
|
||||||
|
M149.333,288c-17.643,0-32-14.357-32-32s14.357-32,32-32c17.643,0,32,14.357,32,32S166.976,288,149.333,288z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 946 KiB |
Before Width: | Height: | Size: 109 B After Width: | Height: | Size: 109 B |
Before Width: | Height: | Size: 696 B After Width: | Height: | Size: 696 B |
Before Width: | Height: | Size: 708 B After Width: | Height: | Size: 708 B |
BIN
public/assets/music/intro.mp3
Normal file
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 274 B |
BIN
public/assets/sounds/attack.wav
Normal file
BIN
public/assets/sounds/button-click.wav
Normal file
BIN
public/assets/sounds/connect.wav
Normal file
BIN
public/assets/sounds/walk.wav
Normal file
BIN
public/assets/tlogo.png
Normal file
After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 302 KiB |
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 400 KiB |
Before Width: | Height: | Size: 453 KiB After Width: | Height: | Size: 454 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 2.1 MiB |
Before Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.1 MiB |
42
src/App.vue
@ -1,48 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<Debug />
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<BackgroundImageLoader />
|
|
||||||
<GmPanel v-if="gameStore.character?.role === 'gm'" />
|
<GmPanel v-if="gameStore.character?.role === 'gm'" />
|
||||||
<component :is="currentScreen" />
|
<component :is="currentScreen" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
|
||||||
import { useZoneEditorStore } from '@/stores/zoneEditorStore'
|
|
||||||
import Notifications from '@/components/utilities/Notifications.vue'
|
|
||||||
import BackgroundImageLoader from '@/components/utilities/BackgroundImageLoader.vue'
|
|
||||||
import GmPanel from '@/components/gameMaster/GmPanel.vue'
|
import GmPanel from '@/components/gameMaster/GmPanel.vue'
|
||||||
import Login from '@/components/screens/Login.vue'
|
|
||||||
import Characters from '@/components/screens/Characters.vue'
|
import Characters from '@/components/screens/Characters.vue'
|
||||||
import Game from '@/components/screens/Game.vue'
|
import Game from '@/components/screens/Game.vue'
|
||||||
import ZoneEditor from '@/components/screens/ZoneEditor.vue'
|
import Loading from '@/components/screens/Loading.vue'
|
||||||
|
import Login from '@/components/screens/Login.vue'
|
||||||
|
import MapEditor from '@/components/screens/MapEditor.vue'
|
||||||
|
import Debug from '@/components/utilities/Debug.vue'
|
||||||
|
import Notifications from '@/components/utilities/Notifications.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { useSoundComposable } from '@/composables/useSoundComposable'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import { computed, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const zoneEditorStore = useZoneEditorStore()
|
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
const { playSound } = useSoundComposable()
|
||||||
|
|
||||||
const currentScreen = computed(() => {
|
const currentScreen = computed(() => {
|
||||||
|
if (!gameStore.game.isLoaded) return Loading
|
||||||
if (!gameStore.connection) return Login
|
if (!gameStore.connection) return Login
|
||||||
if (!gameStore.token) return Login
|
if (!gameStore.token) return Login
|
||||||
if (!gameStore.character) return Characters
|
if (!gameStore.character) return Characters
|
||||||
if (zoneEditorStore.active) return ZoneEditor
|
if (mapEditor.active.value) return MapEditor
|
||||||
return Game
|
return Game
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch zoneEditorStore.active and empty gameStore.game.loadedAssets
|
// Watch mapEditor.active and empty gameStore.game.loadedAssets
|
||||||
watch(
|
watch(
|
||||||
() => zoneEditorStore.active,
|
() => mapEditor.active.value,
|
||||||
() => {
|
() => {
|
||||||
gameStore.game.loadedAssets = []
|
gameStore.game.loadedTextures = []
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// #209: Play sound when a button is pressed
|
// #209: Play sound when a button is pressed
|
||||||
addEventListener('click', (event) => {
|
addEventListener('click', (event) => {
|
||||||
if (!(event.target instanceof HTMLButtonElement)) {
|
const classList = ['btn-cyan', 'btn-red', 'btn-indigo', 'btn-empty', 'btn-sound']
|
||||||
return
|
const target = event.target as HTMLElement
|
||||||
|
// console.log(target) // Uncomment to log the clicked element
|
||||||
|
if (classList.some((className) => target.classList.contains(className))) {
|
||||||
|
playSound('/assets/sounds/button-click.wav')
|
||||||
}
|
}
|
||||||
const audio = new Audio('/assets/music/click-btn.mp3')
|
|
||||||
audio.play()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for "G" key press and toggle the gm panel
|
// Watch for "G" key press and toggle the gm panel
|
||||||
@ -50,7 +56,9 @@ addEventListener('keydown', (event) => {
|
|||||||
if (gameStore.character?.role !== 'gm') return // Only allow toggling the gm panel if the character is a gm
|
if (gameStore.character?.role !== 'gm') return // Only allow toggling the gm panel if the character is a gm
|
||||||
|
|
||||||
// Check if no input is active or focus is on an input
|
// Check if no input is active or focus is on an input
|
||||||
if (event.repeat || event.isComposing || event.defaultPrevented || document.activeElement?.tagName.toUpperCase() === 'INPUT' || document.activeElement?.tagName.toUpperCase() === 'TEXTAREA') return
|
if (event.repeat || event.isComposing || event.defaultPrevented || document.activeElement?.tagName.toUpperCase() === 'INPUT' || document.activeElement?.tagName.toUpperCase() === 'TEXTAREA') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'G') {
|
if (event.key === 'G') {
|
||||||
gameStore.toggleGmPanel()
|
gameStore.toggleGmPanel()
|
||||||
|
10
src/application/config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default {
|
||||||
|
name: import.meta.env.VITE_NAME,
|
||||||
|
domain: import.meta.env.VITE_DOMAIN,
|
||||||
|
environment: import.meta.env.VITE_ENVIRONMENT,
|
||||||
|
server_endpoint: import.meta.env.VITE_SERVER_ENDPOINT,
|
||||||
|
tile_size: {
|
||||||
|
width: Number(import.meta.env.VITE_TILE_SIZE_WIDTH),
|
||||||
|
height: Number(import.meta.env.VITE_TILE_SIZE_HEIGHT)
|
||||||
|
}
|
||||||
|
}
|
61
src/application/enums.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
export enum Direction {
|
||||||
|
POSITIVE,
|
||||||
|
NEGATIVE,
|
||||||
|
UNCHANGED
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SocketEvent {
|
||||||
|
CLOSE = '52',
|
||||||
|
DATA = '51',
|
||||||
|
CHARACTER_CONNECT = '50',
|
||||||
|
CHARACTER_CREATE = '49',
|
||||||
|
CHARACTER_DELETE = '48',
|
||||||
|
CHARACTER_LIST = '47',
|
||||||
|
GM_CHARACTERHAIR_CREATE = '46',
|
||||||
|
GM_CHARACTERHAIR_REMOVE = '45',
|
||||||
|
GM_CHARACTERHAIR_LIST = '44',
|
||||||
|
GM_CHARACTERHAIR_UPDATE = '43',
|
||||||
|
GM_CHARACTERTYPE_CREATE = '42',
|
||||||
|
GM_CHARACTERTYPE_REMOVE = '41',
|
||||||
|
GM_CHARACTERTYPE_LIST = '40',
|
||||||
|
GM_CHARACTERTYPE_UPDATE = '39',
|
||||||
|
GM_ITEM_CREATE = '38',
|
||||||
|
GM_ITEM_REMOVE = '37',
|
||||||
|
GM_ITEM_LIST = '36',
|
||||||
|
GM_ITEM_UPDATE = '35',
|
||||||
|
GM_MAPOBJECT_LIST = '34',
|
||||||
|
GM_MAPOBJECT_REMOVE = '33',
|
||||||
|
GM_MAPOBJECT_UPDATE = '32',
|
||||||
|
GM_MAPOBJECT_UPLOAD = '31',
|
||||||
|
GM_SPRITE_COPY = '30',
|
||||||
|
GM_SPRITE_CREATE = '29',
|
||||||
|
GM_SPRITE_DELETE = '28',
|
||||||
|
GM_SPRITE_LIST = '27',
|
||||||
|
GM_SPRITE_UPDATE = '26',
|
||||||
|
GM_TILE_DELETE = '25',
|
||||||
|
GM_TILE_LIST = '24',
|
||||||
|
GM_TILE_UPDATE = '23',
|
||||||
|
GM_TILE_UPLOAD = '22',
|
||||||
|
GM_MAP_CREATE = '21',
|
||||||
|
GM_MAP_DELETE = '20',
|
||||||
|
GM_MAP_REQUEST = '19',
|
||||||
|
GM_MAP_UPDATE = '18',
|
||||||
|
MAP_CHARACTER_MOVEERROR = '17',
|
||||||
|
DISCONNECT = '16',
|
||||||
|
USER_DISCONNECT = '15',
|
||||||
|
LOGIN = '14',
|
||||||
|
LOGGED_IN = '13',
|
||||||
|
NOTIFICATION = '12',
|
||||||
|
DATE = '11',
|
||||||
|
FAILED = '10',
|
||||||
|
COMPLETED = '9',
|
||||||
|
CONNECTION = '8',
|
||||||
|
WEATHER = '7',
|
||||||
|
CHARACTER_DISCONNECT = '6',
|
||||||
|
MAP_CHARACTER_ATTACK = '5',
|
||||||
|
MAP_CHARACTER_TELEPORT = '4',
|
||||||
|
MAP_CHARACTER_JOIN = '3',
|
||||||
|
MAP_CHARACTER_LEAVE = '2',
|
||||||
|
MAP_CHARACTER_MOVE = '1',
|
||||||
|
CHAT_MESSAGE = '0'
|
||||||
|
}
|
@ -1,18 +1,28 @@
|
|||||||
|
export type UUID = `${string}-${string}-${string}-${string}-${string}`
|
||||||
|
|
||||||
export type Notification = {
|
export type Notification = {
|
||||||
id?: string
|
id?: string
|
||||||
title?: string
|
title?: string
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssetDataT = {
|
export type HttpResponse<T> = {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
data?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextureData = {
|
||||||
key: string
|
key: string
|
||||||
data: string
|
data: string // URL or Base64 encoded blob
|
||||||
group: 'tiles' | 'objects' | 'sprites' | 'sprite_animations' | 'sound' | 'music' | 'ui' | 'font' | 'other'
|
group: 'tiles' | 'map_objects' | 'sprites' | 'sprite_animations' | 'sound' | 'music' | 'ui' | 'font' | 'other'
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
isAnimated?: boolean
|
originX?: number
|
||||||
frameCount?: number
|
originY?: number
|
||||||
|
frameRate?: number
|
||||||
frameWidth?: number
|
frameWidth?: number
|
||||||
frameHeight?: number
|
frameHeight?: number
|
||||||
|
frameCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tile = {
|
export type Tile = {
|
||||||
@ -23,97 +33,91 @@ export type Tile = {
|
|||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Object = {
|
export type MapObject = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
tags: any | null
|
tags: any | null
|
||||||
originX: number
|
originX: number
|
||||||
originY: number
|
originY: number
|
||||||
isAnimated: boolean
|
frameRate: number
|
||||||
frameSpeed: number
|
|
||||||
frameWidth: number
|
frameWidth: number
|
||||||
frameHeight: number
|
frameHeight: number
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
ZoneObject: ZoneObject[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Item = {
|
export type Item = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
|
itemType: ItemType
|
||||||
stackable: boolean
|
stackable: boolean
|
||||||
|
rarity: ItemRarity
|
||||||
|
sprite?: Sprite
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
characters: CharacterItem[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Zone = {
|
export type ItemType = 'WEAPON' | 'HELMET' | 'CHEST' | 'LEGS' | 'BOOTS' | 'GLOVES' | 'RING' | 'NECKLACE'
|
||||||
id: number
|
export type ItemRarity = 'COMMON' | 'UNCOMMON' | 'RARE' | 'EPIC' | 'LEGENDARY'
|
||||||
|
|
||||||
|
export type Map = {
|
||||||
|
id: string
|
||||||
name: string
|
name: string
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
tiles: any | null
|
tiles: string[][]
|
||||||
pvp: boolean
|
pvp: boolean
|
||||||
zoneEffects: ZoneEffect[]
|
mapEffects: MapEffect[]
|
||||||
zoneEventTiles: ZoneEventTile[]
|
mapEventTiles: MapEventTile[]
|
||||||
zoneObjects: ZoneObject[]
|
placedMapObjects: PlacedMapObject[]
|
||||||
characters: Character[]
|
characters: Character[]
|
||||||
chats: Chat[]
|
chats: Chat[]
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ZoneEffect = {
|
export type MapEffect = {
|
||||||
id: string
|
id: string
|
||||||
zoneId: number
|
|
||||||
zone: Zone
|
|
||||||
effect: string
|
effect: string
|
||||||
strength: number
|
strength: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ZoneObject = {
|
export type PlacedMapObject = {
|
||||||
id: string
|
id: string
|
||||||
zoneId: number
|
mapObject: MapObject | string
|
||||||
zone: Zone
|
|
||||||
objectId: string
|
|
||||||
object: Object
|
|
||||||
depth: number
|
|
||||||
isRotated: boolean
|
isRotated: boolean
|
||||||
positionX: number
|
positionX: number
|
||||||
positionY: number
|
positionY: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ZoneEventTileType {
|
export enum MapEventTileType {
|
||||||
BLOCK = 'BLOCK',
|
BLOCK = 'BLOCK',
|
||||||
TELEPORT = 'TELEPORT',
|
TELEPORT = 'TELEPORT',
|
||||||
NPC = 'NPC',
|
NPC = 'NPC',
|
||||||
ITEM = 'ITEM'
|
ITEM = 'ITEM'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ZoneEventTile = {
|
export type MapEventTile = {
|
||||||
id: string
|
id: string
|
||||||
zoneId: number
|
mapid: string
|
||||||
zone: Zone
|
type: MapEventTileType
|
||||||
type: ZoneEventTileType
|
|
||||||
positionX: number
|
positionX: number
|
||||||
positionY: number
|
positionY: number
|
||||||
teleport?: ZoneEventTileTeleport
|
teleport?: MapEventTileTeleport
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ZoneEventTileTeleport = {
|
export type MapEventTileTeleport = {
|
||||||
id: string
|
id: string
|
||||||
zoneEventTileId: string
|
mapEventTile: MapEventTile
|
||||||
zoneEventTile: ZoneEventTile
|
toMap: Map
|
||||||
toZoneId: number
|
|
||||||
toZone: Zone
|
|
||||||
toPositionX: number
|
toPositionX: number
|
||||||
toPositionY: number
|
toPositionY: number
|
||||||
toRotation: number
|
toRotation: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: number
|
id: string
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
characters: Character[]
|
characters: Character[]
|
||||||
@ -133,21 +137,27 @@ export enum CharacterRace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CharacterType = {
|
export type CharacterType = {
|
||||||
id: number
|
id: string
|
||||||
name: string
|
name: string
|
||||||
gender: CharacterGender
|
gender: CharacterGender
|
||||||
race: CharacterRace
|
race: CharacterRace
|
||||||
isEnabledForCharCreation: boolean
|
isSelectable: boolean
|
||||||
characters: Character[]
|
|
||||||
spriteId?: string
|
|
||||||
sprite?: Sprite
|
sprite?: Sprite
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CharacterHair = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
sprite?: Sprite
|
||||||
|
gender: CharacterGender
|
||||||
|
isSelectable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type Character = {
|
export type Character = {
|
||||||
id: number
|
id: string
|
||||||
userId: number
|
userid: string
|
||||||
user: User
|
user: User
|
||||||
name: string
|
name: string
|
||||||
hitpoints: number
|
hitpoints: number
|
||||||
@ -159,32 +169,42 @@ export type Character = {
|
|||||||
positionX: number
|
positionX: number
|
||||||
positionY: number
|
positionY: number
|
||||||
rotation: number
|
rotation: number
|
||||||
zoneId: number
|
characterType: UUID | null
|
||||||
zone: Zone
|
characterHair: UUID | null
|
||||||
characterTypeId: number | null
|
map: UUID
|
||||||
characterType: CharacterType | null
|
|
||||||
chats: Chat[]
|
chats: Chat[]
|
||||||
items: CharacterItem[]
|
items: CharacterItem[]
|
||||||
|
equipment: CharacterEquipment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ZoneCharacter = {
|
export type MapCharacter = {
|
||||||
character: Character
|
character: Character
|
||||||
isMoving?: boolean
|
isMoving: boolean
|
||||||
}
|
isAttacking?: boolean
|
||||||
|
|
||||||
export type ExtendedCharacter = Character & {
|
|
||||||
isMoving?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CharacterItem = {
|
export type CharacterItem = {
|
||||||
id: number
|
id: string
|
||||||
characterId: number
|
|
||||||
character: Character
|
character: Character
|
||||||
itemId: string
|
|
||||||
item: Item
|
item: Item
|
||||||
quantity: number
|
quantity: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CharacterEquipment = {
|
||||||
|
id: string
|
||||||
|
slot: CharacterEquipmentSlotType
|
||||||
|
characterItem: CharacterItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CharacterEquipmentSlotType {
|
||||||
|
HEAD = 'HEAD',
|
||||||
|
BODY = 'BODY',
|
||||||
|
ARMS = 'ARMS',
|
||||||
|
LEGS = 'LEGS',
|
||||||
|
NECK = 'NECK',
|
||||||
|
RING = 'RING'
|
||||||
|
}
|
||||||
|
|
||||||
export type Sprite = {
|
export type Sprite = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -194,41 +214,45 @@ export type Sprite = {
|
|||||||
characterTypes: CharacterType[]
|
characterTypes: CharacterType[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpriteImage {
|
||||||
|
url: string
|
||||||
|
offset: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SpriteAction = {
|
export type SpriteAction = {
|
||||||
id: string
|
id: string
|
||||||
spriteId: string
|
sprite: string
|
||||||
sprite: Sprite
|
|
||||||
action: string
|
action: string
|
||||||
sprites: string[]
|
sprites: SpriteImage[]
|
||||||
originX: number
|
originX: number
|
||||||
originY: number
|
originY: number
|
||||||
isAnimated: boolean
|
|
||||||
isLooping: boolean
|
|
||||||
frameWidth: number
|
frameWidth: number
|
||||||
frameHeight: number
|
frameHeight: number
|
||||||
frameSpeed: number
|
frameRate: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Chat = {
|
export type Chat = {
|
||||||
id: number
|
id: string
|
||||||
characterId: number
|
|
||||||
character: Character
|
character: Character
|
||||||
zoneId: number
|
map: Map
|
||||||
zone: Zone
|
|
||||||
message: string
|
message: string
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorldSettings = {
|
export type WorldSettings = {
|
||||||
date: Date
|
date: Date
|
||||||
isRainEnabled: boolean
|
weatherState: WeatherState
|
||||||
isFogEnabled: boolean
|
|
||||||
fogDensity: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeatherState = {
|
export type WeatherState = {
|
||||||
isRainEnabled: boolean
|
|
||||||
rainPercentage: number
|
rainPercentage: number
|
||||||
isFogEnabled: boolean
|
|
||||||
fogDensity: number
|
fogDensity: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type mapLoadData = {
|
||||||
|
mapId: string
|
||||||
|
characters: MapCharacter[]
|
||||||
|
}
|
35
src/application/utilities.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import config from '@/application/config'
|
||||||
|
import type { HttpResponse } from '@/application/types'
|
||||||
|
import type { BaseStorage } from '@/storage/baseStorage'
|
||||||
|
|
||||||
|
export function uuidv4() {
|
||||||
|
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unduplicateArray(array: any[]) {
|
||||||
|
const arrayToProcess = typeof array.flat === 'function' ? array.flat() : array
|
||||||
|
return [...new Set(arrayToProcess)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadCache<T extends { id: string; updatedAt: Date }>(endpoint: string, storage: BaseStorage<T>) {
|
||||||
|
const request = await fetch(`${config.server_endpoint}/cache/${endpoint}`)
|
||||||
|
const response = (await request.json()) as HttpResponse<T[]>
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
console.error(`Failed to download ${endpoint}:`, response.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = response.data ?? []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
let overwrite = false
|
||||||
|
const existingItem = await storage.getById(item.id)
|
||||||
|
|
||||||
|
if (!existingItem || item.updatedAt > existingItem.updatedAt) {
|
||||||
|
overwrite = true
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.add(item, overwrite)
|
||||||
|
}
|
||||||
|
}
|
@ -23,6 +23,14 @@ body {
|
|||||||
|
|
||||||
// Disable pinch zoom
|
// Disable pinch zoom
|
||||||
touch-action: pan-x pan-y;
|
touch-action: pan-x pan-y;
|
||||||
|
|
||||||
|
// Add custom focus outline
|
||||||
|
*:focus-visible {
|
||||||
|
@apply outline-gray-300;
|
||||||
|
@apply outline;
|
||||||
|
@apply outline-offset-2;
|
||||||
|
@apply rounded-sm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@ -45,7 +53,7 @@ label {
|
|||||||
|
|
||||||
button,
|
button,
|
||||||
a {
|
a {
|
||||||
@apply font-medium drop-shadow-20;
|
@apply font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@ -58,13 +66,20 @@ input {
|
|||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
&[type='number']::-webkit-inner-spin-button,
|
&[type='number']::-webkit-inner-spin-button,
|
||||||
&[type='number']::-webkit-outer-spin-button {
|
&[type='number']::-webkit-outer-spin-button,
|
||||||
|
&[type='radio'] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
.input-field {
|
||||||
@apply px-4 py-2.5 text-base leading-5 focus-visible:outline-none bg-gray border border-solid border-gray-500 rounded text-gray-300;
|
@apply px-4 py-2.5 text-base leading-5 bg-gray border border-solid border-gray-500 rounded text-gray-300 font-default;
|
||||||
|
&:focus-visible {
|
||||||
|
@apply outline-none border-cyan rounded bg-gray-900;
|
||||||
|
}
|
||||||
|
&::placeholder {
|
||||||
|
@apply focus-visible:text-gray-300/50;
|
||||||
|
}
|
||||||
&.inactive {
|
&.inactive {
|
||||||
@apply bg-gray-600/50 hover:cursor-not-allowed;
|
@apply bg-gray-600/50 hover:cursor-not-allowed;
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
@ -73,6 +88,12 @@ input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
&.input-field {
|
||||||
|
@apply appearance-none bg-[url('/assets/icons/mapEditor/dropdown-chevron.svg')] bg-no-repeat bg-[calc(100%_-_10px)_center] bg-[length:20px] text-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.form-field-full {
|
.form-field-full {
|
||||||
@apply w-full flex flex-col mb-5;
|
@apply w-full flex flex-col mb-5;
|
||||||
label {
|
label {
|
||||||
@ -99,11 +120,20 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.btn-red {
|
&.btn-red {
|
||||||
@apply bg-red text-gray-50 text-base leading-5 rounded py-2.5;
|
@apply bg-red-300 text-gray-50 text-base leading-5 rounded py-2.5;
|
||||||
|
|
||||||
&.active,
|
&.active,
|
||||||
&:hover {
|
&:hover {
|
||||||
@apply bg-red-300;
|
@apply bg-red-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-indigo {
|
||||||
|
@apply bg-indigo-500 text-gray-50 text-base leading-5 rounded py-2.5;
|
||||||
|
|
||||||
|
&.active,
|
||||||
|
&:hover {
|
||||||
|
@apply bg-indigo-600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,7 +143,7 @@ button {
|
|||||||
&.active,
|
&.active,
|
||||||
&.selected,
|
&.selected,
|
||||||
&:hover {
|
&:hover {
|
||||||
@apply bg-gray-700 border-gray-700;
|
@apply bg-gray border-gray;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,10 +160,26 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.character {
|
.character.active {
|
||||||
&.active {
|
@apply bg-gray bg-none;
|
||||||
@apply pr-px border-r-0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-open {
|
||||||
|
@apply w-[calc(75%_-_40px)] max-xl:w-[calc(100%_-_360px)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.hair-deselect:has(:checked) {
|
||||||
|
img {
|
||||||
|
@apply brightness-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-border {
|
||||||
|
@apply border border-solid border-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-element {
|
||||||
|
@apply absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-pixel {
|
.text-pixel {
|
||||||
|
@ -1,218 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Scene name="effects" @preload="preloadScene" @create="createScene" @update="updateScene" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Scene } from 'phavuer'
|
|
||||||
import { useZoneStore } from '@/stores/zoneStore'
|
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import type { WeatherState } from '@/types'
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const SUNRISE_HOUR = 6
|
|
||||||
const SUNSET_HOUR = 20
|
|
||||||
const DAY_STRENGTH = 100
|
|
||||||
const NIGHT_STRENGTH = 30
|
|
||||||
|
|
||||||
// Stores
|
|
||||||
const gameStore = useGameStore()
|
|
||||||
const zoneStore = useZoneStore()
|
|
||||||
|
|
||||||
// Scene ref
|
|
||||||
const sceneRef = ref<Phaser.Scene | null>(null)
|
|
||||||
|
|
||||||
// Effect refs
|
|
||||||
const lightEffect = ref<Phaser.GameObjects.Graphics | null>(null)
|
|
||||||
const rainEmitter = ref<Phaser.GameObjects.Particles.ParticleEmitter | null>(null)
|
|
||||||
const fogSprite = ref<Phaser.GameObjects.Sprite | null>(null)
|
|
||||||
|
|
||||||
// State refs
|
|
||||||
const weatherState = ref<WeatherState>({
|
|
||||||
isRainEnabled: false,
|
|
||||||
rainPercentage: 0,
|
|
||||||
isFogEnabled: false,
|
|
||||||
fogDensity: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Scene lifecycle methods
|
|
||||||
const preloadScene = async (scene: Phaser.Scene) => {
|
|
||||||
scene.load.image('raindrop', 'assets/raindrop.png')
|
|
||||||
scene.load.image('fog', 'assets/fog.png')
|
|
||||||
}
|
|
||||||
|
|
||||||
const createScene = async (scene: Phaser.Scene) => {
|
|
||||||
sceneRef.value = scene
|
|
||||||
setupEffects(scene)
|
|
||||||
setupSocketListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateScene = () => {
|
|
||||||
updateEffects()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effect setup
|
|
||||||
const setupEffects = (scene: Phaser.Scene) => {
|
|
||||||
createLightEffect(scene)
|
|
||||||
createRainEffect(scene)
|
|
||||||
createFogEffect(scene)
|
|
||||||
}
|
|
||||||
|
|
||||||
const createLightEffect = (scene: Phaser.Scene) => {
|
|
||||||
lightEffect.value = scene.add.graphics()
|
|
||||||
lightEffect.value.setDepth(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRainEffect = (scene: Phaser.Scene) => {
|
|
||||||
rainEmitter.value = scene.add.particles(0, 0, 'raindrop', {
|
|
||||||
x: { min: 0, max: window.innerWidth },
|
|
||||||
y: -50,
|
|
||||||
quantity: 5,
|
|
||||||
lifespan: 2000,
|
|
||||||
speedY: { min: 300, max: 500 },
|
|
||||||
scale: { start: 0.005, end: 0.005 },
|
|
||||||
alpha: { start: 0.5, end: 0 },
|
|
||||||
blendMode: 'ADD'
|
|
||||||
})
|
|
||||||
rainEmitter.value.setDepth(900)
|
|
||||||
rainEmitter.value.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
const createFogEffect = (scene: Phaser.Scene) => {
|
|
||||||
fogSprite.value = scene.add.sprite(window.innerWidth / 2, window.innerHeight / 2, 'fog')
|
|
||||||
fogSprite.value.setScale(2)
|
|
||||||
fogSprite.value.setAlpha(0)
|
|
||||||
fogSprite.value.setDepth(950)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lighting calculations
|
|
||||||
const calculateLightStrength = (time: Date): number => {
|
|
||||||
const hour = time.getHours()
|
|
||||||
const minute = time.getMinutes()
|
|
||||||
|
|
||||||
let strength = DAY_STRENGTH
|
|
||||||
|
|
||||||
// Night time (10 PM - 6 AM)
|
|
||||||
if (hour >= SUNSET_HOUR || hour < SUNRISE_HOUR) {
|
|
||||||
strength = NIGHT_STRENGTH
|
|
||||||
}
|
|
||||||
// Full daylight (7 AM - 7 PM)
|
|
||||||
else if (hour > SUNRISE_HOUR && hour < SUNSET_HOUR - 2) {
|
|
||||||
strength = DAY_STRENGTH
|
|
||||||
}
|
|
||||||
// Sunrise transition (6 AM - 7 AM)
|
|
||||||
else if (hour === SUNRISE_HOUR) {
|
|
||||||
strength = NIGHT_STRENGTH + ((DAY_STRENGTH - NIGHT_STRENGTH) * minute) / 60
|
|
||||||
}
|
|
||||||
// Sunset transition (8 PM - 10 PM)
|
|
||||||
else if (hour >= SUNSET_HOUR - 2 && hour < SUNSET_HOUR) {
|
|
||||||
const totalMinutes = (hour - (SUNSET_HOUR - 2)) * 60 + minute
|
|
||||||
const transitionProgress = totalMinutes / 120 // 2 hours = 120 minutes
|
|
||||||
strength = DAY_STRENGTH - (DAY_STRENGTH - NIGHT_STRENGTH) * transitionProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
return strength
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effect updates
|
|
||||||
const updateEffects = () => {
|
|
||||||
const effects = zoneStore.zone?.zoneEffects || []
|
|
||||||
|
|
||||||
if (effects.length > 0) {
|
|
||||||
updateZoneEffects(effects)
|
|
||||||
} else {
|
|
||||||
// Make sure we're getting the current time
|
|
||||||
const lightStrength = calculateLightStrength(gameStore.world.date)
|
|
||||||
updateLightEffect(lightStrength)
|
|
||||||
updateWeatherEffects()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateZoneEffects = (effects: any[]) => {
|
|
||||||
// Always update light based on time when zone effects are present
|
|
||||||
updateLightEffect(calculateLightStrength(gameStore.world.date))
|
|
||||||
|
|
||||||
effects.forEach((effect) => {
|
|
||||||
switch (effect.effect) {
|
|
||||||
case 'rain':
|
|
||||||
updateRainEffect(effect.strength)
|
|
||||||
break
|
|
||||||
case 'fog':
|
|
||||||
updateFogEffect(effect.strength)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateWeatherEffects = () => {
|
|
||||||
updateRainEffect(weatherState.value.isRainEnabled ? weatherState.value.rainPercentage : 0)
|
|
||||||
updateFogEffect(weatherState.value.isFogEnabled ? weatherState.value.fogDensity * 100 : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateLightEffect = (strength: number) => {
|
|
||||||
if (!lightEffect.value) return
|
|
||||||
const darkness = 1 - strength / 100
|
|
||||||
lightEffect.value.clear()
|
|
||||||
lightEffect.value.fillStyle(0x000000, darkness)
|
|
||||||
lightEffect.value.fillRect(0, 0, window.innerWidth, window.innerHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRainEffect = (strength: number) => {
|
|
||||||
if (!rainEmitter.value) return
|
|
||||||
if (strength > 0) {
|
|
||||||
rainEmitter.value.start()
|
|
||||||
rainEmitter.value.setQuantity(Math.floor((strength / 100) * 10))
|
|
||||||
} else {
|
|
||||||
rainEmitter.value.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFogEffect = (strength: number) => {
|
|
||||||
if (!fogSprite.value) return
|
|
||||||
fogSprite.value.setAlpha(strength / 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Socket handlers
|
|
||||||
const setupSocketListeners = () => {
|
|
||||||
// Initial weather state
|
|
||||||
gameStore.connection?.emit('weather', (response: WeatherState) => {
|
|
||||||
if (zoneStore.zone?.zoneEffects) return
|
|
||||||
weatherState.value = response
|
|
||||||
updateEffects()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Weather updates
|
|
||||||
gameStore.connection?.on('weather', (data: WeatherState) => {
|
|
||||||
weatherState.value = data
|
|
||||||
updateEffects()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Time updates
|
|
||||||
gameStore.connection?.on('date', () => {
|
|
||||||
if (zoneStore.zone?.zoneEffects) return
|
|
||||||
updateEffects()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateEffectWindowSize = () => {
|
|
||||||
if (rainEmitter.value) rainEmitter.value.updateConfig({ x: { min: 0, max: window.innerWidth } })
|
|
||||||
if (fogSprite.value) {
|
|
||||||
fogSprite.value.setX(window.innerWidth / 2)
|
|
||||||
fogSprite.value.setY(window.innerHeight / 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watchers
|
|
||||||
watch(() => zoneStore.zone?.zoneEffects, updateEffects, { deep: true })
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('resize', updateEffectWindowSize)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('resize', updateEffectWindowSize)
|
|
||||||
if (sceneRef.value) sceneRef.value.scene.remove('effects')
|
|
||||||
gameStore.connection?.off('weather')
|
|
||||||
})
|
|
||||||
</script>
|
|
@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center input-field gap-1">
|
<div class="flex flex-wrap items-center input-field gap-1" @click="focusInput">
|
||||||
<div v-for="(chip, i) in internalValue" :key="i" class="flex gap-2.5 items-center bg-cyan rounded py-1 px-2">
|
<div v-for="(chip, i) in internalValue" :key="i" class="flex gap-2.5 items-center bg-cyan rounded py-1 px-2" role="listitem">
|
||||||
<span class="text-xs text-white">{{ chip }}</span>
|
<span class="text-xs text-white">{{ chip }}</span>
|
||||||
<button type="button" class="text-xs cursor-pointer text-white font-light font-default not-italic hover:text-gray-50" @click="deleteChip(i)" aria-label="Remove chip">×</button>
|
<button type="button" class="text-xs cursor-pointer text-white font-light font-default not-italic hover:text-gray-50" @click.stop="deleteChip(i)" aria-label="Remove tag">×</button>
|
||||||
</div>
|
</div>
|
||||||
<input class="outline-none border-none p-1 text-gray-300" placeholder="Tag name" v-model="currentInput" @keypress.enter.prevent="addChip" @keydown.backspace="handleBackspace" />
|
<input ref="inputRef" class="outline-none border-none p-1 text-gray-300 min-w-[60px] flex-grow" :placeholder="placeholder" v-model.trim="currentInput" @keydown="handleKeydown" @paste="handlePaste" :maxlength="maxChipLength" aria-label="Add new tag" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -14,20 +14,29 @@ import type { Ref } from 'vue'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue?: string[]
|
modelValue?: string[]
|
||||||
|
maxChips?: number
|
||||||
|
maxChipLength?: number
|
||||||
|
placeholder?: string
|
||||||
|
allowDuplicates?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
modelValue: () => []
|
modelValue: () => [],
|
||||||
|
maxChips: 10,
|
||||||
|
maxChipLength: 20,
|
||||||
|
placeholder: 'Add tag',
|
||||||
|
allowDuplicates: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: string[]): void
|
(e: 'update:modelValue', value: string[]): void
|
||||||
|
(e: 'error', message: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const currentInput: Ref<string> = ref('')
|
const currentInput: Ref<string> = ref('')
|
||||||
const internalValue = ref<string[]>([])
|
const internalValue = ref<string[]>([])
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
// Initialize internalValue with props.modelValue
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
@ -36,9 +45,27 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const validateChip = (chip: string): boolean => {
|
||||||
|
if (!chip) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.allowDuplicates && internalValue.value.includes(chip)) {
|
||||||
|
emit('error', 'Duplicate tags are not allowed')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (internalValue.value.length >= props.maxChips) {
|
||||||
|
emit('error', `Maximum ${props.maxChips} tags allowed`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const addChip = () => {
|
const addChip = () => {
|
||||||
const trimmedInput = currentInput.value.trim()
|
const trimmedInput = currentInput.value.trim()
|
||||||
if (trimmedInput && !internalValue.value.includes(trimmedInput)) {
|
if (validateChip(trimmedInput)) {
|
||||||
internalValue.value.push(trimmedInput)
|
internalValue.value.push(trimmedInput)
|
||||||
emit('update:modelValue', internalValue.value)
|
emit('update:modelValue', internalValue.value)
|
||||||
currentInput.value = ''
|
currentInput.value = ''
|
||||||
@ -50,10 +77,36 @@ const deleteChip = (index: number) => {
|
|||||||
emit('update:modelValue', internalValue.value)
|
emit('update:modelValue', internalValue.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBackspace = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Backspace' && currentInput.value === '' && internalValue.value.length > 0) {
|
switch (event.key) {
|
||||||
internalValue.value.pop()
|
case 'Enter':
|
||||||
emit('update:modelValue', internalValue.value)
|
event.preventDefault()
|
||||||
|
addChip()
|
||||||
|
break
|
||||||
|
case 'Backspace':
|
||||||
|
if (currentInput.value === '' && internalValue.value.length > 0) {
|
||||||
|
deleteChip(internalValue.value.length - 1)
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const pastedText = event.clipboardData?.getData('text')
|
||||||
|
if (pastedText) {
|
||||||
|
const chips = pastedText
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((chip) => chip.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
chips.forEach((chip) => {
|
||||||
|
currentInput.value = chip
|
||||||
|
addChip()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusInput = () => {
|
||||||
|
inputRef.value?.focus()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
102
src/components/game/character/Character.vue
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<Container ref="characterContainer" :x="currentPositionX" :y="currentPositionY" :depth="isometricDepth">
|
||||||
|
<ChatBubble :mapCharacter="props.mapCharacter" />
|
||||||
|
<HealthBar :mapCharacter="props.mapCharacter" />
|
||||||
|
<CharacterHair :mapCharacter="props.mapCharacter" />
|
||||||
|
<Sprite ref="characterSprite" :origin-y="1" :flipX="isFlippedX" />
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { type MapCharacter } from '@/application/types'
|
||||||
|
import CharacterHair from '@/components/game/character/partials/CharacterHair.vue'
|
||||||
|
import ChatBubble from '@/components/game/character/partials/ChatBubble.vue'
|
||||||
|
import HealthBar from '@/components/game/character/partials/HealthBar.vue'
|
||||||
|
import { useCharacterSpriteComposable } from '@/composables/useCharacterSpriteComposable'
|
||||||
|
import { useSoundComposable } from '@/composables/useSoundComposable'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { useMapStore } from '@/stores/mapStore'
|
||||||
|
import { Container, Sprite, useScene } from 'phavuer'
|
||||||
|
import { onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tileMap: Phaser.Tilemaps.Tilemap
|
||||||
|
mapCharacter: MapCharacter
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const scene = useScene()
|
||||||
|
|
||||||
|
const { characterContainer, characterSprite, currentPositionX, currentPositionY, isometricDepth, isFlippedX, updatePosition, playAnimation, updateSprite, initializeSprite, cleanup } = useCharacterSpriteComposable(scene, props.tileMap, props.mapCharacter)
|
||||||
|
const { playSound, stopSound } = useSoundComposable()
|
||||||
|
|
||||||
|
const handlePositionUpdate = (newValues: any, oldValues: any) => {
|
||||||
|
if (!newValues) return
|
||||||
|
|
||||||
|
if (!oldValues || newValues.positionX !== oldValues.positionX || newValues.positionY !== oldValues.positionY) {
|
||||||
|
updatePosition(newValues.positionX, newValues.positionY)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValues.isMoving !== oldValues?.isMoving || newValues.rotation !== oldValues?.rotation) {
|
||||||
|
updateSprite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays walk sound when character is moving
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => props.mapCharacter.isMoving,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
playSound('/assets/sounds/walk.wav', false, true)
|
||||||
|
} else {
|
||||||
|
stopSound('/assets/sounds/walk.wav')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays attack animation and sound when character is attacking
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => props.mapCharacter.isAttacking,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
playAnimation('attack')
|
||||||
|
playSound('/assets/sounds/attack.wav', false, true)
|
||||||
|
} else {
|
||||||
|
stopSound('/assets/sounds/attack.wav')
|
||||||
|
}
|
||||||
|
mapStore.updateCharacterProperty(props.mapCharacter.character.id, 'isAttacking', false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles position updates and movement delay
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => ({
|
||||||
|
positionX: props.mapCharacter.character.positionX,
|
||||||
|
positionY: props.mapCharacter.character.positionY,
|
||||||
|
isMoving: props.mapCharacter.isMoving,
|
||||||
|
rotation: props.mapCharacter.character.rotation,
|
||||||
|
isAttacking: props.mapCharacter.isAttacking
|
||||||
|
}),
|
||||||
|
(oldValues, newValues) => {
|
||||||
|
handlePositionUpdate(oldValues, newValues)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await initializeSprite()
|
||||||
|
if (props.mapCharacter.character.id === gameStore.character!.id) {
|
||||||
|
scene.cameras.main.startFollow(characterContainer.value as Phaser.GameObjects.Container)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
</script>
|
54
src/components/game/character/partials/CharacterHair.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<Image v-bind="imageProps" v-if="gameStore.isTextureLoaded(texture)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MapCharacter, Sprite as SpriteT } from '@/application/types'
|
||||||
|
import { loadSpriteTextures } from '@/services/textureService'
|
||||||
|
import { CharacterHairStorage, CharacterTypeStorage, SpriteStorage } from '@/storage/storages'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { Image, useScene } from 'phavuer'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
mapCharacter: MapCharacter
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const scene = useScene()
|
||||||
|
const hairSpriteId = ref('')
|
||||||
|
const sprite = ref<SpriteT | null>(null)
|
||||||
|
|
||||||
|
const texture = computed(() => {
|
||||||
|
const { rotation } = props.mapCharacter.character
|
||||||
|
const direction = [0, 6].includes(rotation) ? 'back' : 'front'
|
||||||
|
|
||||||
|
return `${hairSpriteId.value}-${direction}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFlippedX = computed(() => [6, 4].includes(props.mapCharacter.character.rotation ?? 0))
|
||||||
|
|
||||||
|
const imageProps = computed(() => {
|
||||||
|
const direction = [0, 6].includes(props.mapCharacter.character.rotation ?? 0) ? 'back' : 'front'
|
||||||
|
const spriteAction = sprite.value?.spriteActions?.find((spriteAction) => spriteAction.action === direction)
|
||||||
|
|
||||||
|
return {
|
||||||
|
depth: 9999,
|
||||||
|
originX: Number(spriteAction?.originX) ?? 0,
|
||||||
|
originY: Number(spriteAction?.originY) ?? 0,
|
||||||
|
flipX: isFlippedX.value,
|
||||||
|
texture: texture.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const characterHairStorage = new CharacterHairStorage()
|
||||||
|
const spriteId = await characterHairStorage.getSpriteId(props.mapCharacter.character.characterHair!)
|
||||||
|
if (!spriteId) return
|
||||||
|
|
||||||
|
hairSpriteId.value = spriteId
|
||||||
|
const spriteStorage = new SpriteStorage()
|
||||||
|
sprite.value = await spriteStorage.getById(spriteId)
|
||||||
|
await loadSpriteTextures(scene, spriteId)
|
||||||
|
})
|
||||||
|
</script>
|
45
src/components/game/character/partials/ChatBubble.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<Container ref="characterChatContainer">
|
||||||
|
<RoundRectangle @create="createChatBubble" :origin-x="0.5" :origin-y="7.5" :fillColor="0xffffff" :width="194" :height="21" :radius="20" />
|
||||||
|
<Text @create="createChatText" :style="{ fontSize: 13, fontFamily: 'Arial', color: '#000' }" />
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MapCharacter } from '@/application/types'
|
||||||
|
import { Container, refObj, RoundRectangle, Text, useGame } from 'phavuer'
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
mapCharacter: MapCharacter
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const game = useGame()
|
||||||
|
const characterChatContainer = refObj<Phaser.GameObjects.Container>()
|
||||||
|
|
||||||
|
const createChatBubble = (container: Phaser.GameObjects.Container) => {
|
||||||
|
container.setName(`${props.mapCharacter.character.name}_chatBubble`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createChatText = (text: Phaser.GameObjects.Text) => {
|
||||||
|
text.setName(`${props.mapCharacter.character.name}_chatText`)
|
||||||
|
text.setFontSize(13)
|
||||||
|
text.setFontFamily('Arial')
|
||||||
|
text.setOrigin(0.5, 10.9)
|
||||||
|
text.setResolution(2)
|
||||||
|
|
||||||
|
// Fix text alignment on Windows and Android
|
||||||
|
if (game.device.os.windows || game.device.os.android) {
|
||||||
|
text.setOrigin(0.5, 9.75)
|
||||||
|
|
||||||
|
if (game.device.browser.firefox) {
|
||||||
|
text.setOrigin(0.5, 10.9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
characterChatContainer.value!.setName(`${props.mapCharacter.character!.name}_chatContainer`)
|
||||||
|
characterChatContainer.value!.setVisible(false)
|
||||||
|
})
|
||||||
|
</script>
|
34
src/components/game/character/partials/HealthBar.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<Container :depth="999">
|
||||||
|
<Text @create="createNicknameText" :text="props.mapCharacter.character.name" />
|
||||||
|
<RoundRectangle :origin-x="0.5" :origin-y="18.5" :fillColor="0xffffff" :width="74" :height="6" :radius="5" />
|
||||||
|
<RoundRectangle :origin-x="0.5" :origin-y="36.4" :fillColor="0x00b3b3" :width="70" :height="3" :radius="5" />
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MapCharacter } from '@/application/types'
|
||||||
|
import { Container, RoundRectangle, Text, useGame } from 'phavuer'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
mapCharacter: MapCharacter
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const game = useGame()
|
||||||
|
|
||||||
|
const createNicknameText = (text: Phaser.GameObjects.Text) => {
|
||||||
|
text.setFontSize(13)
|
||||||
|
text.setFontFamily('Arial')
|
||||||
|
text.setOrigin(0.5, 9)
|
||||||
|
text.setResolution(2)
|
||||||
|
|
||||||
|
// Fix text alignment on Windows and Android
|
||||||
|
if (game.device.os.windows || game.device.os.android) {
|
||||||
|
text.setOrigin(0.5, 8)
|
||||||
|
|
||||||
|
if (game.device.browser.firefox) {
|
||||||
|
text.setOrigin(0.5, 9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
173
src/components/game/gui/CharacterProfile.vue
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<div class="absolute" v-if="gameStore.uiSettings.isCharacterProfileOpen" :style="modalStyle">
|
||||||
|
<div class="relative">
|
||||||
|
<img src="/assets/ui-elements/profile-ui-box-outer.svg" class="absolute w-full h-full" alt="" />
|
||||||
|
<img src="/assets/ui-elements/profile-ui-box-inner.svg" class="absolute left-2 bottom-2 w-[calc(100%_-_16px)] h-[calc(100%_-_40px)]" alt="" />
|
||||||
|
<div @mousedown="startDrag" class="cursor-move px-5 py-2.5 flex justify-between items-center relative">
|
||||||
|
<span class="text-xs text-white font-thin">Character Profile [Alt+C]</span>
|
||||||
|
<button @click="gameStore.uiSettings.isCharacterProfileOpen = false" class="w-3.5 h-3.5 m-0 p-0 relative hover:rotate-180 transition-transform duration-300 ease-in-out">
|
||||||
|
<img draggable="false" src="/assets/icons/modal/close-button-white.svg" class="w-full h-full" alt="Close button icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="py-4 px-6 flex flex-col gap-7 relative z-10">
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm m-0 font-bold text-white tracking-wide">{{ gameStore.character?.name }}</p>
|
||||||
|
<span class="text-xs">{{ gameStore.character?.experience }} / 18.600XP</span>
|
||||||
|
</div>
|
||||||
|
<a class="hover:cursor-pointer bg-[url('/assets/ui-elements/button-ui-box-textured-small.svg')] bg-no-repeat block w-8 h-8 relative mx-[3px]">
|
||||||
|
<img class="hover:drop-shadow-default w-3.5 h-3.5 m-[9px] object-contain" draggable="false" src="/assets/icons/plus-green-icon.svg" alt="Plus button icon" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600">
|
||||||
|
<span class="absolute w-full top-1/2 -translate-y-1/2 text-[6px] text-center">CROWN</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600">
|
||||||
|
<span class="absolute w-full top-1/2 -translate-y-1/2 text-[6px] text-center">R-HAND</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-0.5 items-end">
|
||||||
|
<div class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600">
|
||||||
|
<span class="absolute w-full top-1/2 -translate-y-1/2 text-[6px] text-center">L-HAND</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-6 h-6 default-border rounded-sm bg-gray relative hover:bg-gray-600">
|
||||||
|
<span class="absolute w-full top-1/2 -translate-y-1/2 text-[6px] text-center">RING</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img src="/assets/placeholders/inventory_player.png" class="w-8 h-auto" alt="Player character sprite" />
|
||||||
|
<div class="flex flex-col items-end gap-0.5">
|
||||||
|
<div class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600">
|
||||||
|
<img class="w-6 h-6 center-element" src="/assets/icons/profile/helmet.svg" alt="Helmet icon" />
|
||||||
|
</div>
|
||||||
|
<div class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600">
|
||||||
|
<img class="w-6 h-6 center-element" src="/assets/icons/profile/chestplate.svg" alt="Chestplate icon" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-0.5 items-end">
|
||||||
|
<div class="w-6 h-6 default-border rounded-sm bg-gray relative hover:bg-gray-600">
|
||||||
|
<img class="w-4 h-4 center-element" src="/assets/icons/profile/boots.svg" alt="Boots icon" />
|
||||||
|
</div>
|
||||||
|
<div class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600">
|
||||||
|
<img class="w-6 h-6 center-element" src="/assets/icons/profile/legs.svg" alt="Legs icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="w-[105px] h-px mb-[3px] flex justify-between">
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
<div class="w-[101px] h-full bg-gray-300"></div>
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-11">
|
||||||
|
<p class="m-0 text-xs text-white tracking-wide">Health</p>
|
||||||
|
<span class="m-0 text-xs text-white tracking-wide">+ 15</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-[105px] h-px my-[3px] flex justify-between">
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
<div class="w-[101px] h-full bg-gray-300"></div>
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-11">
|
||||||
|
<p class="m-0 text-xs text-white tracking-wide">Health</p>
|
||||||
|
<span class="m-0 text-xs text-white tracking-wide">+ 15</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-[105px] h-px mt-[3px] flex justify-between">
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
<div class="w-[101px] h-full bg-gray-300"></div>
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="w-[105px] h-px mb-[3px] flex justify-between">
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
<div class="w-[101px] h-full bg-gray-300"></div>
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-11">
|
||||||
|
<p class="m-0 text-xs text-white tracking-wide">Health</p>
|
||||||
|
<span class="m-0 text-xs text-white tracking-wide">+ 15</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-[105px] h-px my-[3px] flex justify-between">
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
<div class="w-[101px] h-full bg-gray-300"></div>
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-11">
|
||||||
|
<p class="m-0 text-xs text-white tracking-wide">Health</p>
|
||||||
|
<span class="m-0 text-xs text-white tracking-wide">+ 15</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-[105px] h-px mt-[3px] flex justify-between">
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
<div class="w-[101px] h-full bg-gray-300"></div>
|
||||||
|
<div class="w-px h-full bg-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-rows-4 grid-cols-6 gap-0.5">
|
||||||
|
<div v-for="n in 24" class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
|
||||||
|
const width = ref(286)
|
||||||
|
const height = ref(483)
|
||||||
|
const x = ref(window.innerWidth / 2 - 143)
|
||||||
|
const y = ref(window.innerHeight / 2 - 241)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
const modalStyle = computed(() => ({
|
||||||
|
top: `${y.value}px`,
|
||||||
|
left: `${x.value}px`,
|
||||||
|
width: `${width.value}px`,
|
||||||
|
height: `${height.value}px`
|
||||||
|
}))
|
||||||
|
|
||||||
|
function startDrag(event: MouseEvent) {
|
||||||
|
isDragging.value = true
|
||||||
|
const startX = event.clientX - x.value
|
||||||
|
const startY = event.clientY - y.value
|
||||||
|
|
||||||
|
function drag(event: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
x.value = event.clientX - startX
|
||||||
|
y.value = event.clientY - startY
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrag() {
|
||||||
|
isDragging.value = false
|
||||||
|
removeEventListener('mousemove', drag)
|
||||||
|
removeEventListener('mouseup', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener('mousemove', drag)
|
||||||
|
addEventListener('mouseup', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyPress(event: KeyboardEvent) {
|
||||||
|
if (event.altKey && event.key === 'c') {
|
||||||
|
gameStore.toggleCharacterProfile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
addEventListener('keydown', keyPress)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
removeEventListener('keydown', keyPress)
|
||||||
|
})
|
||||||
|
</script>
|
@ -2,12 +2,12 @@
|
|||||||
<div class="w-full md:min-w-[350px] max-w-[750px] flex flex-col absolute left-1/2 -translate-x-1/2 bottom-5">
|
<div class="w-full md:min-w-[350px] max-w-[750px] flex flex-col absolute left-1/2 -translate-x-1/2 bottom-5">
|
||||||
<div ref="chatWindow" class="w-full overflow-auto h-32 mb-5 bg-gray rounded-md border-2 border-solid border-gray-500 text-gray-300" v-show="gameStore.uiSettings.isChatOpen">
|
<div ref="chatWindow" class="w-full overflow-auto h-32 mb-5 bg-gray rounded-md border-2 border-solid border-gray-500 text-gray-300" v-show="gameStore.uiSettings.isChatOpen">
|
||||||
<div v-for="message in chats" class="flex-col py-2 items-center p-3">
|
<div v-for="message in chats" class="flex-col py-2 items-center p-3">
|
||||||
<span class="text-ellipsis overflow-hidden whitespace-nowrap text-sm" :class="{ 'text-cyan-300': gameStore.character?.role == 'gm' }">{{ message.character.name }}</span>
|
<span class="text-ellipsis overflow-hidden whitespace-nowrap text-sm" :class="{ 'text-cyan-300': gameStore.character?.role == 'gm' }">{{ message.character }}</span>
|
||||||
<p class="text-gray-50 m-0">{{ message.message }}</p>
|
<p class="text-gray-50 m-0">{{ message.message }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-96 mx-auto relative">
|
<div class="w-96 mx-auto relative">
|
||||||
<img src="/assets/icons/ingameUI/chat-icon.svg" class="absolute top-1/2 -translate-y-1/2 left-2.5 h-4 w-4 opacity-50" />
|
<img src="/assets/icons/ingameUI/chat-icon.svg" class="absolute top-1/2 -translate-y-1/2 left-2.5 h-4 w-4 opacity-50" alt="" />
|
||||||
<input
|
<input
|
||||||
class="w-[332px] h-8 rounded-sm text-xs font-default pl-8 pr-4 py-0 bg-gray-600 border-2 border-solid border-gray-500 text-gray-300 bg-[url('/assets/ui-texture.png')] bg-no-repeat bg-cover focus:outline-none focus:ring-0 focus:border-cyan-800"
|
class="w-[332px] h-8 rounded-sm text-xs font-default pl-8 pr-4 py-0 bg-gray-600 border-2 border-solid border-gray-500 text-gray-300 bg-[url('/assets/ui-texture.png')] bg-no-repeat bg-cover focus:outline-none focus:ring-0 focus:border-cyan-800"
|
||||||
placeholder="Type something..."
|
placeholder="Type something..."
|
||||||
@ -21,33 +21,41 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, ref, nextTick } from 'vue'
|
import { SocketEvent } from '@/application/enums'
|
||||||
import { onClickOutside } from '@vueuse/core'
|
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import type { Chat } from '@/types'
|
import { useMapStore } from '@/stores/mapStore'
|
||||||
import { useZoneStore } from '@/stores/zoneStore'
|
import { onClickOutside, useFocus } from '@vueuse/core'
|
||||||
import { useScene } from 'phavuer'
|
import { useScene } from 'phavuer'
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
const scene = useScene()
|
const scene = useScene()
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const zoneStore = useZoneStore()
|
const mapStore = useMapStore()
|
||||||
|
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const chats = ref([] as Chat[])
|
const chats = ref<{ character: string; message: string }[]>([])
|
||||||
const chatWindow = ref<HTMLElement | null>(null)
|
const chatWindow = ref<HTMLElement | null>(null)
|
||||||
const chatInput = ref<HTMLElement | null>(null)
|
const chatInput = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
onClickOutside(chatInput, event => unfocusChat(event, chatInput.value as HTMLElement))
|
const { focused } = useFocus(chatInput)
|
||||||
|
|
||||||
|
function focusChat(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter' && !focused.value) {
|
||||||
|
focused.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickOutside(chatInput, (event) => unfocusChat(event, chatInput.value as HTMLElement))
|
||||||
|
|
||||||
function unfocusChat(event: Event, targetElement: HTMLElement) {
|
function unfocusChat(event: Event, targetElement: HTMLElement) {
|
||||||
if (!(event.target instanceof Node) || !targetElement.contains(event.target)) {
|
if (!(event.target instanceof Node) || !targetElement.contains(event.target)) {
|
||||||
targetElement.blur();
|
targetElement.blur()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMessage = () => {
|
const sendMessage = () => {
|
||||||
if (!message.value.trim()) return
|
if (!message.value.trim()) return
|
||||||
gameStore.connection?.emit('chat:message', { message: message.value }, (response: boolean) => {})
|
gameStore.connection?.emit(SocketEvent.CHAT_MESSAGE, { message: message.value }, (response: boolean) => {})
|
||||||
message.value = ''
|
message.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,18 +79,30 @@ const scrollToBottom = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
gameStore.connection?.on('chat:message', (data: Chat) => {
|
gameStore.connection?.on(SocketEvent.CHAT_MESSAGE, (data: { character: string; message: string }) => {
|
||||||
chats.value.push(data)
|
if (!data.character || !data.message) return
|
||||||
|
|
||||||
|
chats.value.push({ character: data.character, message: data.message })
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|
||||||
if (!zoneStore.characterLoaded) return
|
const characterContainer = scene.children.getByName(data.character) as Phaser.GameObjects.Container
|
||||||
|
if (!characterContainer) {
|
||||||
|
console.log('No character container found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const charChatContainer = scene.children.getByName(data.character.name + '_chatContainer') as Phaser.GameObjects.Container
|
const characterChatContainer = characterContainer.getByName(data.character + '_chatContainer') as Phaser.GameObjects.Container
|
||||||
if (!charChatContainer) return
|
if (!characterChatContainer) {
|
||||||
|
console.log('No character chat container found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const chatBubble = charChatContainer.getByName(data.character.name + '_chatBubble') as Phaser.GameObjects.Container
|
const chatBubble = characterChatContainer.getByName(data.character + '_chatBubble') as Phaser.GameObjects.Container
|
||||||
const chatText = charChatContainer.getByName(data.character.name + '_chatText') as Phaser.GameObjects.Text
|
const chatText = characterChatContainer.getByName(data.character + '_chatText') as Phaser.GameObjects.Text
|
||||||
if (!chatText || !chatBubble) return
|
if (!chatText || !chatBubble) {
|
||||||
|
console.log('No chat text or bubble found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
function calculateTextWidth(text: string, font: string, fontSize: number): number {
|
function calculateTextWidth(text: string, font: string, fontSize: number): number {
|
||||||
// Create a canvas element
|
// Create a canvas element
|
||||||
@ -107,28 +127,33 @@ gameStore.connection?.on('chat:message', (data: Chat) => {
|
|||||||
// setText but with max. char limit of 90
|
// setText but with max. char limit of 90
|
||||||
chatText.setText(data.message.substring(0, 90))
|
chatText.setText(data.message.substring(0, 90))
|
||||||
|
|
||||||
charChatContainer.setVisible(true)
|
characterChatContainer.setVisible(true)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide chat bubble after a few seconds
|
* Hide chat bubble after a few seconds
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Clear any existing hide timer
|
// Clear any existing hide timer
|
||||||
if (charChatContainer.getData('hideTimer')) {
|
if (characterChatContainer.getData('hideTimer')) {
|
||||||
clearTimeout(charChatContainer.getData('hideTimer'))
|
clearTimeout(characterChatContainer.getData('hideTimer'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set a new hide timer
|
// Set a new hide timer
|
||||||
const hideTimer = setTimeout(() => {
|
const hideTimer = setTimeout(() => {
|
||||||
charChatContainer.setVisible(false)
|
characterChatContainer.setVisible(false)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
// Store the timer on the container itself
|
// Store the timer on the container itself
|
||||||
charChatContainer.setData('hideTimer', hideTimer)
|
characterChatContainer.setData('hideTimer', hideTimer)
|
||||||
})
|
})
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
addEventListener('keydown', focusChat)
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
gameStore.connection?.off('chat:message')
|
gameStore.connection?.off(SocketEvent.CHAT_MESSAGE)
|
||||||
|
removeEventListener('keydown', focusChat)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
20
src/components/game/gui/Clock.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="absolute top-0 right-4 hidden lg:block" v-if="gameStore.world.date && typeof gameStore.world.date === 'object'">
|
||||||
|
<p class="text-white text-lg">
|
||||||
|
{{ useDateFormat(gameStore.world.date, 'YYYY/MM/DD HH:mm') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { useDateFormat } from '@vueuse/core'
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
gameStore.connection?.off(SocketEvent.DATE)
|
||||||
|
})
|
||||||
|
</script>
|
@ -6,7 +6,7 @@
|
|||||||
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="group-hover:cursor-pointer bg-[url('/assets/ui-elements/button-ui-box-textured.svg')] bg-no-repeat block w-[42px] h-[42px] relative">
|
<a class="group-hover:cursor-pointer bg-[url('/assets/ui-elements/button-ui-box-textured.svg')] bg-no-repeat block w-[42px] h-[42px] relative">
|
||||||
<img class="group-hover:drop-shadow-default w-6 h-5 mx-[9px] my-[11px] object-contain" draggable="false" src="/assets/icons/ingameUI/menu-icon.svg" />
|
<img class="group-hover:drop-shadow-default w-6 h-5 mx-[9px] my-[11px] object-contain" draggable="false" src="/assets/icons/ingameUI/menu-icon.svg" alt="Menu button icon" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="menu-item group relative" @click="gameStore.toggleCharacterProfile">
|
<li class="menu-item group relative" @click="gameStore.toggleCharacterProfile">
|
||||||
@ -15,7 +15,7 @@
|
|||||||
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="group-hover:cursor-pointer bg-[url('/assets/ui-elements/button-ui-box-textured.svg')] bg-no-repeat block w-[42px] h-[42px] relative">
|
<a class="group-hover:cursor-pointer bg-[url('/assets/ui-elements/button-ui-box-textured.svg')] bg-no-repeat block w-[42px] h-[42px] relative">
|
||||||
<img class="group-hover:drop-shadow-default w-8 h-8 m-[5px] object-contain" draggable="false" src="/assets/avatar/default/head.png" />
|
<img class="group-hover:drop-shadow-default w-8 h-8 m-[5px] object-contain" draggable="false" src="/assets/placeholders/head.png" alt="User profile button icon" />
|
||||||
<p class="absolute bottom-0 -right-1.5 m-0 max-w-4 font-ui z-10 text-white text-[12px] leading-[6px] drop-shadow-pixel"><span class="font-ui text-white text-[8px] ml-0.5">LVL</span> {{ characterLevel }}</p>
|
<p class="absolute bottom-0 -right-1.5 m-0 max-w-4 font-ui z-10 text-white text-[12px] leading-[6px] drop-shadow-pixel"><span class="font-ui text-white text-[8px] ml-0.5">LVL</span> {{ characterLevel }}</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@ -24,8 +24,8 @@
|
|||||||
<p class="absolute w-full bottom-0 m-0 text-xs leading-6 text-white">Open Chat</p>
|
<p class="absolute w-full bottom-0 m-0 text-xs leading-6 text-white">Open Chat</p>
|
||||||
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="group-hover:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border border-b-4 border-solid rounded border-gray-500 block w-[34px] h-[31px]">
|
<a class="group-hover:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border-2 border-solid rounded border-gray-500 block w-[34px] h-[31px]">
|
||||||
<img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/chat-icon.svg" />
|
<img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/chat-icon.svg" alt="Open chat button icon" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="menu-item group relative">
|
<li class="menu-item group relative">
|
||||||
@ -33,8 +33,8 @@
|
|||||||
<p class="absolute w-full bottom-0 m-0 text-xs leading-6 text-white">World map</p>
|
<p class="absolute w-full bottom-0 m-0 text-xs leading-6 text-white">World map</p>
|
||||||
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="group-hover:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border border-b-4 border-solid rounded border-gray-500 block w-[34px] h-[31px]">
|
<a class="group-hover:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border-2 border-solid rounded border-gray-500 block w-[34px] h-[31px]">
|
||||||
<img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/map-icon.svg" />
|
<img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/map-icon.svg" alt="World map button icon" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="menu-item group relative">
|
<li class="menu-item group relative">
|
||||||
@ -42,8 +42,8 @@
|
|||||||
<p class="absolute w-full bottom-0 m-0 text-xs leading-6 text-white">Users</p>
|
<p class="absolute w-full bottom-0 m-0 text-xs leading-6 text-white">Users</p>
|
||||||
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
<div class="group-hover:block absolute -left-2 bg-gray-500 h-3.5 w-2 [clip-path:polygon(100%_0,_0_50%,_100%_100%)] top-1/2 -translate-y-1/2 hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="group-hover:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border border-b-4 border-solid rounded border-gray-500 block w-[34px] h-[31px]">
|
<a class="group-hover:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border-2 border-solid rounded border-gray-500 block w-[34px] h-[31px]">
|
||||||
<img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/socials-icon.svg" />
|
<img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/socials-icon.svg" alt="Users button icon" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
@ -1,16 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="absolute top-4 right-4 hidden lg:block">
|
<div class="absolute top-4 right-4 hidden lg:block">
|
||||||
<div class="w-40 h-40 rounded-full border border-solid border-gray-500 bg-[url('/assets/ui-texture.png')] bg-no-repeat">
|
<div class="w-40 h-40 rounded-full default-border bg-[url('/assets/ui-texture.png')] bg-no-repeat">
|
||||||
<div class="w-40 h-40 rounded-full shadow-inner"></div>
|
<div class="w-40 h-40 rounded-full shadow-inner"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute -bottom-3 left-1/2 -translate-x-1/2 flex gap-1">
|
<div class="absolute -bottom-3 left-1/2 -translate-x-1/2 flex gap-1">
|
||||||
<button class="w-6 h-6 relative p-0">
|
<button class="w-6 h-6 relative p-0">
|
||||||
<img class="absolute w-3 h-3 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" src="/assets/icons/plus-icon.svg" />
|
<img class="w-3 h-3 center-element" src="/assets/icons/plus-icon.svg" alt="Zoom-in button icon" />
|
||||||
<img class="w-full h-full" src="/assets/ui-elements/button-ui-box-textured.svg" />
|
<img class="w-full h-full" src="/assets/ui-elements/button-ui-box-textured.svg" alt="" />
|
||||||
</button>
|
</button>
|
||||||
<button class="w-6 h-6 relative p-0">
|
<button class="w-6 h-6 relative p-0">
|
||||||
<img class="absolute w-3 h-3 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" src="/assets/icons/minus-icon.svg" />
|
<img class="w-3 h-3 center-element" src="/assets/icons/minus-icon.svg" alt="Zoom-out button icon" />
|
||||||
<img class="w-full h-full" src="/assets/ui-elements/button-ui-box-textured.svg" />
|
<img class="w-full h-full" src="/assets/ui-elements/button-ui-box-textured.svg" alt="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
48
src/components/game/map/Characters.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<Character v-for="item in mapStore.characters" :key="item.character.id" :tileMap :mapCharacter="item" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { MapCharacter, UUID } from '@/application/types'
|
||||||
|
import Character from '@/components/game/character/Character.vue'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { useMapStore } from '@/stores/mapStore'
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tileMap: Phaser.Tilemaps.Tilemap
|
||||||
|
}>()
|
||||||
|
|
||||||
|
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_JOIN, (data: MapCharacter) => {
|
||||||
|
mapStore.addCharacter(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_LEAVE, (characterId: UUID) => {
|
||||||
|
mapStore.removeCharacter(characterId)
|
||||||
|
})
|
||||||
|
|
||||||
|
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_MOVE, (data: { characterId: UUID; positionX: number; positionY: number; rotation: number; isMoving: boolean }) => {
|
||||||
|
mapStore.updateCharacterPosition(data)
|
||||||
|
// @TODO: Replace with universal class, composable or store
|
||||||
|
if (data.characterId === gameStore.character?.id) {
|
||||||
|
gameStore.character!.positionX = data.positionX
|
||||||
|
gameStore.character!.positionY = data.positionY
|
||||||
|
gameStore.character!.rotation = data.rotation
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_ATTACK, (characterId: UUID) => {
|
||||||
|
mapStore.updateCharacterProperty(characterId, 'isAttacking', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_ATTACK)
|
||||||
|
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_MOVE)
|
||||||
|
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_JOIN)
|
||||||
|
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_LEAVE)
|
||||||
|
})
|
||||||
|
</script>
|
70
src/components/game/map/Map.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<MapTiles v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer />
|
||||||
|
<PlacedMapObjects v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer />
|
||||||
|
<Characters v-if="tileMap && mapStore.characters" :tileMap />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { mapLoadData } from '@/application/types'
|
||||||
|
import { unduplicateArray } from '@/application/utilities'
|
||||||
|
import Characters from '@/components/game/map/Characters.vue'
|
||||||
|
import MapTiles from '@/components/game/map/MapTiles.vue'
|
||||||
|
import PlacedMapObjects from '@/components/game/map/PlacedMapObjects.vue'
|
||||||
|
import { createTileLayer, createTileMap, loadTileTexturesFromMapTileArray } from '@/services/mapService'
|
||||||
|
import { MapStorage } from '@/storage/storages'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { useMapStore } from '@/stores/mapStore'
|
||||||
|
import { useScene } from 'phavuer'
|
||||||
|
import { onMounted, onUnmounted, shallowRef, watch } from 'vue'
|
||||||
|
|
||||||
|
const scene = useScene()
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
|
||||||
|
const mapStorage = new MapStorage()
|
||||||
|
|
||||||
|
const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
|
||||||
|
const tileMapLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
gameStore.connection?.on(SocketEvent.MAP_CHARACTER_TELEPORT, (data: mapLoadData) => {
|
||||||
|
mapStore.setMapId(data.mapId)
|
||||||
|
mapStore.setCharacters(data.characters)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
if (!mapStore.mapId) return
|
||||||
|
|
||||||
|
const map = await mapStorage.getById(mapStore.mapId)
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
await loadTileTexturesFromMapTileArray(mapStore.mapId, scene)
|
||||||
|
|
||||||
|
tileMap.value = createTileMap(scene, map)
|
||||||
|
tileMapLayer.value = createTileLayer(tileMap.value, unduplicateArray(map.tiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mapStore.mapId,
|
||||||
|
async () => {
|
||||||
|
await initialize()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!mapStore.mapId) return
|
||||||
|
await initialize()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (tileMap.value) {
|
||||||
|
tileMap.value.destroyLayer('tiles')
|
||||||
|
tileMap.value.removeAllLayers()
|
||||||
|
tileMap.value.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.connection?.off(SocketEvent.MAP_CHARACTER_TELEPORT)
|
||||||
|
})
|
||||||
|
</script>
|
32
src/components/game/map/MapTiles.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<Controls v-if="tileMapLayer" :layer="tileMapLayer" :depth="0" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Controls from '@/components/utilities/Controls.vue'
|
||||||
|
import { loadTileTexturesFromMapTileArray, placeTiles } from '@/services/mapService'
|
||||||
|
import { MapStorage } from '@/storage/storages'
|
||||||
|
import { useMapStore } from '@/stores/mapStore'
|
||||||
|
import { useScene } from 'phavuer'
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
const scene = useScene()
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const mapStorage = new MapStorage()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tileMap: Phaser.Tilemaps.Tilemap
|
||||||
|
tileMapLayer: Phaser.Tilemaps.TilemapLayer
|
||||||
|
}>()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!mapStore.mapId) return
|
||||||
|
|
||||||
|
const map = await mapStorage.getById(mapStore.mapId)
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
await loadTileTexturesFromMapTileArray(mapStore.mapId, scene)
|
||||||
|
|
||||||
|
placeTiles(props.tileMap, props.tileMapLayer, map.tiles)
|
||||||
|
})
|
||||||
|
</script>
|
31
src/components/game/map/PlacedMapObjects.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<PlacedMapObject v-for="placedMapObject in items" :tileMap :tileMapLayer :placedMapObject />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PlacedMapObject as PlacedMapObjectT } from '@/application/types'
|
||||||
|
import PlacedMapObject from '@/components/game/map/partials/PlacedMapObject.vue'
|
||||||
|
import { MapStorage } from '@/storage/storages'
|
||||||
|
import { useMapStore } from '@/stores/mapStore'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tileMap: Phaser.Tilemaps.Tilemap
|
||||||
|
tileMapLayer: TilemapLayer
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const mapStorage = new MapStorage()
|
||||||
|
const items = ref<PlacedMapObjectT[]>([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!mapStore.mapId) return
|
||||||
|
|
||||||
|
const map = await mapStorage.getById(mapStore.mapId)
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
items.value = map.placedMapObjects
|
||||||
|
})
|
||||||
|
</script>
|
82
src/components/game/map/partials/PlacedMapObject.vue
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<Image v-if="mapObject && gameStore.isTextureLoaded(props.placedMapObject.mapObject as string)" v-bind="imageProps" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import config from '@/application/config'
|
||||||
|
import type { MapObject, PlacedMapObject } from '@/application/types'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { calculateIsometricDepth, loadMapObjectTextures, tileToWorldXY } from '@/services/mapService'
|
||||||
|
import { MapObjectStorage } from '@/storage/storages'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { Image, useScene } from 'phavuer'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import Tilemap = Phaser.Tilemaps.Tilemap
|
||||||
|
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
placedMapObject: PlacedMapObject
|
||||||
|
tileMap: Tilemap
|
||||||
|
tileMapLayer: TilemapLayer
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const scene = useScene()
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
|
||||||
|
const mapObject = ref<MapObject>()
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
if (!props.placedMapObject.mapObject) return
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if mapObject is an string or object, if its an object we assume its a mapObject and change it to a string
|
||||||
|
* We do this because this component is shared with the map editor, which gets sent the mapObject as an object by the server
|
||||||
|
*/
|
||||||
|
if (typeof props.placedMapObject.mapObject === 'object') {
|
||||||
|
// @ts-ignore
|
||||||
|
props.placedMapObject.mapObject = props.placedMapObject.mapObject.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapObjectStorage = new MapObjectStorage()
|
||||||
|
const _mapObject = await mapObjectStorage.getById(props.placedMapObject.mapObject as string)
|
||||||
|
if (!_mapObject) return
|
||||||
|
|
||||||
|
mapObject.value = _mapObject
|
||||||
|
|
||||||
|
await loadMapObjectTextures([_mapObject], scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateObjectPlacement(mapObj: PlacedMapObject): { x: number; y: number } {
|
||||||
|
let position = tileToWorldXY(props.tileMapLayer, mapObj.positionX, mapObj.positionY)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: position.worldPositionX - mapObject.value!.frameWidth / 2,
|
||||||
|
y: position.worldPositionY - mapObject.value!.frameHeight / 2 + config.tile_size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
await initialize()
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<Modal :isModalOpen="gameStore.uiSettings.isGmPanelOpen" @modal:close="() => gameStore.toggleGmPanel()" :modal-width="1000" :modal-height="650" :can-full-screen="true" :disable-bg-texture="true">
|
<Modal :isModalOpen="gameStore.uiSettings.isGmPanelOpen" @modal:close="() => gameStore.toggleGmPanel()" :modal-width="1000" :modal-height="650" :is-full-screen="true" :bg-style="'dark'">
|
||||||
<template #modalHeader>
|
<template #modalHeader>
|
||||||
<div class="flex gap-1.5 flex-wrap">
|
<div class="flex gap-1.5 flex-wrap">
|
||||||
<button @mousedown.stop class="btn-cyan py-1.5 px-4 min-w-24">General</button>
|
<button @mousedown.stop class="btn-cyan py-1.5 px-4 min-w-24">General</button>
|
||||||
<button @mousedown.stop class="btn-cyan py-1.5 px-4 min-w-24">Users</button>
|
<button @mousedown.stop class="btn-cyan py-1.5 px-4 min-w-24">Users</button>
|
||||||
<button @mousedown.stop class="btn-cyan py-1.5 px-4 min-w-24">Chats</button>
|
<button @mousedown.stop class="btn-cyan py-1.5 px-4 min-w-24">Chats</button>
|
||||||
<button @mousedown.stop class="btn-cyan active py-1.5 px-4 min-w-24">Asset manager</button>
|
<button @mousedown.stop class="btn-cyan active py-1.5 px-4 min-w-24">Asset manager</button>
|
||||||
<button class="btn-cyan py-1.5 px-4 min-w-24" type="button" @click="() => zoneEditorStore.toggleActive()">Zone manager</button>
|
<button class="btn-cyan py-1.5 px-4 min-w-24" type="button" @click="mapEditor.toggleActive()">Map editor</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #modalBody>
|
<template #modalBody>
|
||||||
@ -18,14 +18,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import Modal from '@/components/utilities/Modal.vue'
|
|
||||||
import AssetManager from '@/components/gameMaster/assetManager/AssetManager.vue'
|
import AssetManager from '@/components/gameMaster/assetManager/AssetManager.vue'
|
||||||
|
import Modal from '@/components/utilities/Modal.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import { useZoneEditorStore } from '@/stores/zoneEditorStore'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const zoneEditorStore = useZoneEditorStore()
|
|
||||||
|
|
||||||
let toggle = ref('asset-manager')
|
let toggle = ref('asset-manager')
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,85 +1,79 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full w-full relative">
|
<div class="flex gap-4 h-[calc(100%_-_32px)] w-[calc(100%_-_32px)] relative m-4">
|
||||||
<div class="w-2/12 flex flex-col relative overflow-auto">
|
<div class="w-2/12 flex flex-col relative overflow-auto rounded-md default-border bg-gray p-2.5">
|
||||||
<!-- Asset Categories -->
|
<!-- Asset Categories -->
|
||||||
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': selectedCategory === 'tiles' }" @click="() => (selectedCategory = 'tiles')">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group" :class="{ 'bg-cyan': selectedCategory === 'tiles' }" @click="() => (selectedCategory = 'tiles')">
|
||||||
<span :class="{ 'text-white': selectedCategory === 'tiles' }">Tiles</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': selectedCategory === 'tiles' }">Tiles</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': selectedCategory === 'objects' }" @click="() => (selectedCategory = 'objects')">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group" :class="{ 'bg-cyan': selectedCategory === 'map_objects' }" @click="() => (selectedCategory = 'map_objects')">
|
||||||
<span :class="{ 'text-white': selectedCategory === 'objects' }">Objects</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': selectedCategory === 'map_objects' }">Map objects</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': selectedCategory === 'sprites' }" @click="() => (selectedCategory = 'sprites')">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group" :class="{ 'bg-cyan': selectedCategory === 'sprites' }" @click="() => (selectedCategory = 'sprites')">
|
||||||
<span :class="{ 'text-white': selectedCategory === 'sprites' }">Sprites</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': selectedCategory === 'sprites' }">Sprites</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group" :class="{ 'bg-cyan': selectedCategory === 'items' }" @click="() => (selectedCategory = 'items')">
|
||||||
<span>Items</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': selectedCategory === 'items' }">Items</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group">
|
||||||
<span>NPC's</span>
|
<span class="group-hover:text-white">NPC's</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': selectedCategory === 'shops' }" @click="() => (selectedCategory = 'shops')">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group" :class="{ 'bg-cyan/80': selectedCategory === 'shops' }" @click="() => (selectedCategory = 'shops')">
|
||||||
<span :class="{ 'text-white': selectedCategory === 'shops' }">Shops</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': selectedCategory === 'shops' }">Shops</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': selectedCategory === 'characterTypes' }" @click="() => (selectedCategory = 'characterTypes')">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group" :class="{ 'bg-cyan/80': selectedCategory === 'characterTypes' }" @click="() => (selectedCategory = 'characterTypes')">
|
||||||
<span :class="{ 'text-white': selectedCategory === 'characterTypes' }">Character types</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': selectedCategory === 'characterTypes' }">Character types</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': selectedCategory === 'characterHair' }" @click="() => (selectedCategory = 'characterHair')">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group" :class="{ 'bg-cyan/80': selectedCategory === 'characterHair' }" @click="() => (selectedCategory = 'characterHair')">
|
||||||
<span :class="{ 'text-white': selectedCategory === 'characterHair' }">Character hair</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': selectedCategory === 'characterHair' }">Character hair</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group">
|
||||||
<span>Mounts</span>
|
<span class="group-hover:text-white">Mounts</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group">
|
||||||
<span>Pets</span>
|
<span class="group-hover:text-white">Pets</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
<a class="relative p-2.5 hover:cursor-pointer">
|
<a class="relative p-2.5 hover:cursor-pointer hover:bg-cyan rounded group">
|
||||||
<span>Emoticons</span>
|
<span class="group-hover:text-white">Emoticons</span>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute w-px bg-gray-500 h-full top-0 left-1/6"></div>
|
|
||||||
|
|
||||||
<!-- Assets list -->
|
<!-- Assets list -->
|
||||||
<div class="overflow-auto h-full w-4/12 flex flex-col relative">
|
<div class="overflow-auto h-full w-4/12 flex flex-col relative">
|
||||||
<TileList v-if="selectedCategory === 'tiles'" />
|
<TileList v-if="selectedCategory === 'tiles'" />
|
||||||
<ObjectList v-if="selectedCategory === 'objects'" />
|
<MapObjectList v-if="selectedCategory === 'map_objects'" />
|
||||||
<SpriteList v-if="selectedCategory === 'sprites'" />
|
<SpriteList v-if="selectedCategory === 'sprites'" />
|
||||||
|
<ItemList v-if="selectedCategory === 'items'" />
|
||||||
<CharacterTypeList v-if="selectedCategory === 'characterTypes'" />
|
<CharacterTypeList v-if="selectedCategory === 'characterTypes'" />
|
||||||
|
<CharacterHairList v-if="selectedCategory === 'characterHair'" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute w-px bg-gray-500 h-full top-0 left-1/2"></div>
|
|
||||||
|
|
||||||
<!-- Asset details -->
|
<!-- Asset details -->
|
||||||
<div class="flex w-1/2 after:hidden flex-col relative overflow-auto">
|
<div class="flex w-7/12 after:hidden flex-col relative overflow-auto">
|
||||||
<TileDetails v-if="selectedCategory === 'tiles' && assetManagerStore.selectedTile" />
|
<TileDetails v-if="selectedCategory === 'tiles' && assetManagerStore.selectedTile" />
|
||||||
<ObjectDetails v-if="selectedCategory === 'objects' && assetManagerStore.selectedObject" />
|
<MapObjectDetails v-if="selectedCategory === 'map_objects' && assetManagerStore.selectedMapObject" />
|
||||||
<SpriteDetails v-if="selectedCategory === 'sprites' && assetManagerStore.selectedSprite" />
|
<SpriteDetails v-if="selectedCategory === 'sprites' && assetManagerStore.selectedSprite" />
|
||||||
|
<ItemDetails v-if="selectedCategory === 'items' && assetManagerStore.selectedItem" />
|
||||||
<CharacterTypeDetails v-if="selectedCategory === 'characterTypes' && assetManagerStore.selectedCharacterType" />
|
<CharacterTypeDetails v-if="selectedCategory === 'characterTypes' && assetManagerStore.selectedCharacterType" />
|
||||||
|
<CharacterHairDetails v-if="selectedCategory === 'characterHair' && assetManagerStore.selectedCharacterHair" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import CharacterHairDetails from '@/components/gameMaster/assetManager/partials/characterHair/CharacterHairDetails.vue'
|
||||||
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
import CharacterHairList from '@/components/gameMaster/assetManager/partials/characterHair/CharacterHairList.vue'
|
||||||
import TileList from '@/components/gameMaster/assetManager/partials/tile/TileList.vue'
|
|
||||||
import TileDetails from '@/components/gameMaster/assetManager/partials/tile/TileDetails.vue'
|
|
||||||
import ObjectList from '@/components/gameMaster/assetManager/partials/object/ObjectList.vue'
|
|
||||||
import ObjectDetails from '@/components/gameMaster/assetManager/partials/object/ObjectDetails.vue'
|
|
||||||
import SpriteList from '@/components/gameMaster/assetManager/partials/sprite/SpriteList.vue'
|
|
||||||
import SpriteDetails from '@/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue'
|
|
||||||
import CharacterTypeList from '@/components/gameMaster/assetManager/partials/characterType/CharacterTypeList.vue'
|
|
||||||
import CharacterTypeDetails from '@/components/gameMaster/assetManager/partials/characterType/CharacterTypeDetails.vue'
|
import CharacterTypeDetails from '@/components/gameMaster/assetManager/partials/characterType/CharacterTypeDetails.vue'
|
||||||
|
import CharacterTypeList from '@/components/gameMaster/assetManager/partials/characterType/CharacterTypeList.vue'
|
||||||
|
import ItemDetails from '@/components/gameMaster/assetManager/partials/item/itemDetails.vue'
|
||||||
|
import ItemList from '@/components/gameMaster/assetManager/partials/item/itemList.vue'
|
||||||
|
import MapObjectDetails from '@/components/gameMaster/assetManager/partials/mapObject/MapObjectDetails.vue'
|
||||||
|
import MapObjectList from '@/components/gameMaster/assetManager/partials/mapObject/MapObjectList.vue'
|
||||||
|
import SpriteDetails from '@/components/gameMaster/assetManager/partials/sprite/SpriteDetails.vue'
|
||||||
|
import SpriteList from '@/components/gameMaster/assetManager/partials/sprite/SpriteList.vue'
|
||||||
|
import TileDetails from '@/components/gameMaster/assetManager/partials/tile/TileDetails.vue'
|
||||||
|
import TileList from '@/components/gameMaster/assetManager/partials/tile/TileList.vue'
|
||||||
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const assetManagerStore = useAssetManagerStore()
|
const assetManagerStore = useAssetManagerStore()
|
||||||
const selectedCategory = ref('tiles')
|
const selectedCategory = ref('tiles')
|
||||||
|
@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full overflow-auto">
|
||||||
|
<div class="p-2.5 block rounded-md default-border bg-gray">
|
||||||
|
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveCharacterHair">
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input v-model="characterName" class="input-field" type="text" name="name" placeholder="Character Type Name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="gender">Gender</label>
|
||||||
|
<select v-model="characterGender" class="input-field" name="gender">
|
||||||
|
<option v-for="gender in genderOptions" :key="gender" :value="gender">{{ gender }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="isSelectable">Is selectable</label>
|
||||||
|
<select v-model="characterIsSelectable" class="input-field" name="isSelectable">
|
||||||
|
<option :value="false">No</option>
|
||||||
|
<option :value="true">Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="spriteId">Sprite</label>
|
||||||
|
<select v-model="characterSpriteId" class="input-field" name="spriteId">
|
||||||
|
<option disabled selected value="">Select sprite</option>
|
||||||
|
<option v-for="sprite in assetManagerStore.spriteList" :key="sprite.id" :value="sprite.id">{{ sprite.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn-cyan px-4 py-1.5 min-w-24" type="submit">Save</button>
|
||||||
|
<button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.prevent="removeCharacterHair">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { CharacterGender, CharacterHair, Sprite } from '@/application/types'
|
||||||
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const assetManagerStore = useAssetManagerStore()
|
||||||
|
|
||||||
|
const selectedCharacterHair = computed(() => assetManagerStore.selectedCharacterHair)
|
||||||
|
|
||||||
|
const characterName = ref('')
|
||||||
|
const characterGender = ref<CharacterGender>('MALE' as CharacterGender.MALE)
|
||||||
|
const characterIsSelectable = ref<boolean>(false)
|
||||||
|
const characterSpriteId = ref<string | null | undefined>(null)
|
||||||
|
|
||||||
|
const genderOptions: CharacterGender[] = ['MALE' as CharacterGender.MALE, 'FEMALE' as CharacterGender.FEMALE]
|
||||||
|
|
||||||
|
if (!selectedCharacterHair.value) {
|
||||||
|
console.error('No character hair selected')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCharacterHair.value) {
|
||||||
|
characterName.value = selectedCharacterHair.value.name
|
||||||
|
characterGender.value = selectedCharacterHair.value.gender
|
||||||
|
characterIsSelectable.value = selectedCharacterHair.value.isSelectable
|
||||||
|
characterSpriteId.value = selectedCharacterHair.value.sprite?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCharacterHair() {
|
||||||
|
if (!selectedCharacterHair.value) return
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_REMOVE, { id: selectedCharacterHair.value.id }, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to remove character hair')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshCharacterHairList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCharacterHairList(unsetSelectedCharacterHair = true) {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => {
|
||||||
|
assetManagerStore.setCharacterHairList(response)
|
||||||
|
|
||||||
|
if (unsetSelectedCharacterHair) {
|
||||||
|
assetManagerStore.setSelectedCharacterHair(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCharacterHair() {
|
||||||
|
const characterHairData = {
|
||||||
|
id: selectedCharacterHair.value!.id,
|
||||||
|
name: characterName.value,
|
||||||
|
gender: characterGender.value,
|
||||||
|
isSelectable: characterIsSelectable.value,
|
||||||
|
spriteId: characterSpriteId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_UPDATE, characterHairData, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to save character type')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshCharacterHairList(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedCharacterHair, (characterHair: CharacterHair | null) => {
|
||||||
|
if (!characterHair) return
|
||||||
|
characterName.value = characterHair.name
|
||||||
|
characterGender.value = characterHair.gender
|
||||||
|
characterIsSelectable.value = characterHair.isSelectable
|
||||||
|
characterSpriteId.value = characterHair.sprite?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!selectedCharacterHair.value) return
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
|
||||||
|
assetManagerStore.setSpriteList(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
assetManagerStore.setSelectedCharacterHair(null)
|
||||||
|
})
|
||||||
|
</script>
|
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative mb-5 flex items-center gap-x-2.5">
|
||||||
|
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
||||||
|
<label for="create-character" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2.5 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
||||||
|
<button class="p-0 h-5" id="create-character" @click="createNewCharacterHair">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="white">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-bind="containerProps" class="flex-1 overflow-y-auto relative p-2.5 rounded-md default-border bg-gray" @scroll="onScroll">
|
||||||
|
<div v-bind="wrapperProps" ref="elementToScroll" class="flex flex-col gap-2.5">
|
||||||
|
<a
|
||||||
|
v-for="{ data: characterHair } in list"
|
||||||
|
:key="characterHair.id"
|
||||||
|
class="relative p-2.5 cursor-pointer block rounded hover:bg-cyan group"
|
||||||
|
:class="{ 'bg-cyan': assetManagerStore.selectedCharacterHair?.id === characterHair.id }"
|
||||||
|
@click="assetManagerStore.setSelectedCharacterHair(characterHair as CharacterHair)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span class="group-hover:text-white" :class="{ 'text-white': assetManagerStore.selectedCharacterHair?.id === characterHair.id }">{{ characterHair.name }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="absolute w-12 h-12 bottom-2.5 right-2.5">
|
||||||
|
<button class="fixed min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
||||||
|
<img class="invert w-8 h-8 center-element rotate-180" src="/assets/icons/mapEditor/chevron.svg" alt="" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { CharacterHair } from '@/application/types'
|
||||||
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { useVirtualList } from '@vueuse/core'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const assetManagerStore = useAssetManagerStore()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const hasScrolled = ref(false)
|
||||||
|
const elementToScroll = ref()
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
// Trigger a re-render of the virtual list
|
||||||
|
virtualList.value?.scrollTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewCharacterHair = () => {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_CREATE, {}, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to create new character type')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => {
|
||||||
|
assetManagerStore.setCharacterHairList(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCharacterHairs = computed(() => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return assetManagerStore.characterHairList
|
||||||
|
}
|
||||||
|
return assetManagerStore.characterHairList.filter((character) => character.name.toLowerCase().includes(searchQuery.value.toLowerCase()))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(filteredCharacterHairs, {
|
||||||
|
itemHeight: 48
|
||||||
|
})
|
||||||
|
|
||||||
|
const virtualList = ref({ scrollTo })
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
let scrollTop = elementToScroll.value.style.marginTop.replace('px', '')
|
||||||
|
|
||||||
|
if (scrollTop > 80) {
|
||||||
|
hasScrolled.value = true
|
||||||
|
} else if (scrollTop <= 80) {
|
||||||
|
hasScrolled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTop() {
|
||||||
|
virtualList.value?.scrollTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERHAIR_LIST, {}, (response: CharacterHair[]) => {
|
||||||
|
assetManagerStore.setCharacterHairList(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full overflow-auto">
|
<div class="h-full overflow-auto">
|
||||||
<div class="m-2.5 p-2.5 block">
|
<div class="p-2.5 block rounded-md default-border bg-gray">
|
||||||
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveCharacterType">
|
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveCharacterType">
|
||||||
<div class="form-field-full">
|
<div class="form-field-full">
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
@ -19,8 +19,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field-full">
|
<div class="form-field-full">
|
||||||
<label for="isEnabledForCharCreation">Is enabled for character creation</label>
|
<label for="isSelectable">Is selectable</label>
|
||||||
<select v-model="characterIsEnabledForCharCreation" class="input-field" name="isEnabledForCharCreation">
|
<select v-model="characterIsSelectable" class="input-field" name="isSelectable">
|
||||||
<option :value="false">No</option>
|
<option :value="false">No</option>
|
||||||
<option :value="true">Yes</option>
|
<option :value="true">Yes</option>
|
||||||
</select>
|
</select>
|
||||||
@ -40,10 +40,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CharacterType, CharacterGender, CharacterRace, Sprite } from '@/types'
|
import { SocketEvent } from '@/application/enums'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import type { CharacterGender, CharacterRace, CharacterType, Sprite } from '@/application/types'
|
||||||
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'
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const assetManagerStore = useAssetManagerStore()
|
const assetManagerStore = useAssetManagerStore()
|
||||||
@ -53,7 +54,7 @@ const selectedCharacterType = computed(() => assetManagerStore.selectedCharacter
|
|||||||
const characterName = ref('')
|
const characterName = ref('')
|
||||||
const characterGender = ref<CharacterGender>('MALE' as CharacterGender.MALE)
|
const characterGender = ref<CharacterGender>('MALE' as CharacterGender.MALE)
|
||||||
const characterRace = ref<CharacterRace>('HUMAN' as CharacterRace.HUMAN)
|
const characterRace = ref<CharacterRace>('HUMAN' as CharacterRace.HUMAN)
|
||||||
const characterIsEnabledForCharCreation = 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]
|
||||||
@ -67,14 +68,14 @@ if (selectedCharacterType.value) {
|
|||||||
characterName.value = selectedCharacterType.value.name
|
characterName.value = selectedCharacterType.value.name
|
||||||
characterGender.value = selectedCharacterType.value.gender
|
characterGender.value = selectedCharacterType.value.gender
|
||||||
characterRace.value = selectedCharacterType.value.race
|
characterRace.value = selectedCharacterType.value.race
|
||||||
characterIsEnabledForCharCreation.value = selectedCharacterType.value.isEnabledForCharCreation
|
characterIsSelectable.value = selectedCharacterType.value.isSelectable
|
||||||
characterSpriteId.value = selectedCharacterType.value.spriteId
|
characterSpriteId.value = selectedCharacterType.value.sprite?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCharacterType() {
|
function removeCharacterType() {
|
||||||
if (!selectedCharacterType.value) return
|
if (!selectedCharacterType.value) return
|
||||||
|
|
||||||
gameStore.connection?.emit('gm:characterType:remove', { id: selectedCharacterType.value.id }, (response: boolean) => {
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_REMOVE, { id: selectedCharacterType.value.id }, (response: boolean) => {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
console.error('Failed to remove character type')
|
console.error('Failed to remove character type')
|
||||||
return
|
return
|
||||||
@ -84,7 +85,7 @@ function removeCharacterType() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
|
function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
|
||||||
gameStore.connection?.emit('gm:characterType:list', {}, (response: CharacterType[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => {
|
||||||
assetManagerStore.setCharacterTypeList(response)
|
assetManagerStore.setCharacterTypeList(response)
|
||||||
|
|
||||||
if (unsetSelectedCharacterType) {
|
if (unsetSelectedCharacterType) {
|
||||||
@ -99,11 +100,11 @@ function saveCharacterType() {
|
|||||||
name: characterName.value,
|
name: characterName.value,
|
||||||
gender: characterGender.value,
|
gender: characterGender.value,
|
||||||
race: characterRace.value,
|
race: characterRace.value,
|
||||||
isEnabledForCharCreation: characterIsEnabledForCharCreation.value,
|
isSelectable: characterIsSelectable.value,
|
||||||
spriteId: characterSpriteId.value
|
spriteId: characterSpriteId.value
|
||||||
}
|
}
|
||||||
|
|
||||||
gameStore.connection?.emit('gm:characterType:update', characterTypeData, (response: boolean) => {
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_UPDATE, characterTypeData, (response: boolean) => {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
console.error('Failed to save character type')
|
console.error('Failed to save character type')
|
||||||
return
|
return
|
||||||
@ -117,14 +118,14 @@ watch(selectedCharacterType, (characterType: CharacterType | null) => {
|
|||||||
characterName.value = characterType.name
|
characterName.value = characterType.name
|
||||||
characterGender.value = characterType.gender
|
characterGender.value = characterType.gender
|
||||||
characterRace.value = characterType.race
|
characterRace.value = characterType.race
|
||||||
characterIsEnabledForCharCreation.value = characterType.isEnabledForCharCreation
|
characterIsSelectable.value = characterType.isSelectable
|
||||||
characterSpriteId.value = characterType.spriteId
|
characterSpriteId.value = characterType.sprite?.id
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!selectedCharacterType.value) return
|
if (!selectedCharacterType.value) return
|
||||||
|
|
||||||
gameStore.connection?.emit('gm:sprite:list', {}, (response: Sprite[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
|
||||||
assetManagerStore.setSpriteList(response)
|
assetManagerStore.setSpriteList(response)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,36 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative p-2.5 flex items-center gap-x-2.5">
|
<div class="relative mb-5 flex items-center gap-x-2.5">
|
||||||
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
||||||
<label for="create-character" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
<label for="create-character" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2.5 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
||||||
<button class="p-0 h-5" id="create-character" @click="createNewCharacterType">
|
<button class="p-0 h-5" id="create-character" @click="createNewCharacterType">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="white">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="white">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-bind="containerProps" class="overflow-y-auto relative" @scroll="onScroll">
|
<div v-bind="containerProps" class="flex-1 overflow-y-auto relative p-2.5 rounded-md default-border bg-gray" @scroll="onScroll">
|
||||||
<div v-bind="wrapperProps" ref="elementToScroll">
|
<div v-bind="wrapperProps" ref="elementToScroll" class="flex flex-col gap-2.5">
|
||||||
<a v-for="{ data: characterType } in list" :key="characterType.id" class="relative p-2.5 cursor-pointer block" :class="{ 'bg-cyan/80': assetManagerStore.selectedCharacterType?.id === characterType.id }" @click="assetManagerStore.setSelectedCharacterType(characterType as CharacterType)">
|
<a
|
||||||
|
v-for="{ data: characterType } in list"
|
||||||
|
:key="characterType.id"
|
||||||
|
class="relative p-2.5 cursor-pointer block rounded hover:bg-cyan group"
|
||||||
|
:class="{ 'bg-cyan': assetManagerStore.selectedCharacterType?.id === characterType.id }"
|
||||||
|
@click="assetManagerStore.setSelectedCharacterType(characterType as CharacterType)"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<span :class="{ 'text-white': assetManagerStore.selectedCharacterType?.id === characterType.id }">{{ characterType.name }}</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': assetManagerStore.selectedCharacterType?.id === characterType.id }">{{ characterType.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button class="left-[calc(50%_-_60px)] fixed bottom-2.5 min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
<div class="absolute w-12 h-12 bottom-2.5 right-2.5">
|
||||||
<img class="absolute invert w-8 h-8 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rotate-180" src="/assets/icons/zoneEditor/chevron.svg" alt="" />
|
<button class="fixed min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
||||||
|
<img class="invert w-8 h-8 center-element rotate-180" src="/assets/icons/mapEditor/chevron.svg" alt="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { SocketEvent } from '@/application/enums'
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import type { CharacterType } from '@/application/types'
|
||||||
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
import type { CharacterType } from '@/types'
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import { useVirtualList } from '@vueuse/core'
|
import { useVirtualList } from '@vueuse/core'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const assetManagerStore = useAssetManagerStore()
|
const assetManagerStore = useAssetManagerStore()
|
||||||
@ -46,13 +53,13 @@ const handleSearch = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createNewCharacterType = () => {
|
const createNewCharacterType = () => {
|
||||||
gameStore.connection?.emit('gm:characterType:create', {}, (response: boolean) => {
|
gameStore.connection?.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('gm:characterType:list', {}, (response: CharacterType[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => {
|
||||||
assetManagerStore.setCharacterTypeList(response)
|
assetManagerStore.setCharacterTypeList(response)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -86,7 +93,7 @@ function toTop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
gameStore.connection?.emit('gm:characterType:list', {}, (response: CharacterType[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => {
|
||||||
assetManagerStore.setCharacterTypeList(response)
|
assetManagerStore.setCharacterTypeList(response)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full overflow-auto">
|
||||||
|
<div class="p-2.5 block rounded-md default-border bg-gray">
|
||||||
|
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveItem">
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input v-model="itemName" class="input-field" type="text" name="name" placeholder="Item Name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<input v-model="itemDescription" class="input-field" type="text" name="description" placeholder="Item Description" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="itemType">Type</label>
|
||||||
|
<select v-model="itemType" class="input-field" name="itemType">
|
||||||
|
<option v-for="type in itemTypeOptions" :key="type" :value="type">{{ type }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="rarity">Rarity</label>
|
||||||
|
<select v-model="itemRarity" class="input-field" name="rarity">
|
||||||
|
<option v-for="rarity in rarityOptions" :key="rarity" :value="rarity">{{ rarity }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="stackable">Stackable</label>
|
||||||
|
<select v-model="itemStackable" class="input-field" name="stackable">
|
||||||
|
<option :value="false">No</option>
|
||||||
|
<option :value="true">Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="spriteId">Sprite</label>
|
||||||
|
<select v-model="itemSpriteId" class="input-field" name="spriteId">
|
||||||
|
<option disabled selected value="">Select sprite</option>
|
||||||
|
<option v-for="sprite in assetManagerStore.spriteList" :key="sprite.id" :value="sprite.id">{{ sprite.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<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="removeItem">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { Item, ItemRarity, ItemType, Sprite } from '@/application/types'
|
||||||
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const assetManagerStore = useAssetManagerStore()
|
||||||
|
|
||||||
|
const selectedItem = computed(() => assetManagerStore.selectedItem)
|
||||||
|
|
||||||
|
const itemName = ref('')
|
||||||
|
const itemDescription = ref('')
|
||||||
|
const itemType = ref<ItemType>('WEAPON' as ItemType)
|
||||||
|
const itemRarity = ref<ItemRarity>('COMMON' as ItemRarity)
|
||||||
|
const itemStackable = ref<boolean>(false)
|
||||||
|
const itemSpriteId = ref<string | null | undefined>(null)
|
||||||
|
|
||||||
|
const itemTypeOptions: ItemType[] = ['WEAPON', 'HELMET', 'CHEST', 'LEGS', 'BOOTS', 'GLOVES', 'RING', 'NECKLACE']
|
||||||
|
const rarityOptions: ItemRarity[] = ['COMMON', 'UNCOMMON', 'RARE', 'EPIC', 'LEGENDARY']
|
||||||
|
|
||||||
|
if (!selectedItem.value) {
|
||||||
|
console.error('No item selected')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedItem.value) {
|
||||||
|
itemName.value = selectedItem.value.name
|
||||||
|
itemDescription.value = selectedItem.value.description || ''
|
||||||
|
itemType.value = selectedItem.value.itemType
|
||||||
|
itemRarity.value = selectedItem.value.rarity
|
||||||
|
itemStackable.value = selectedItem.value.stackable
|
||||||
|
itemSpriteId.value = selectedItem.value.spriteId
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem() {
|
||||||
|
if (!selectedItem.value) return
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_ITEM_REMOVE, { id: selectedItem.value.id }, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to remove item')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshItemList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshItemList(unsetSelectedItem = true) {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => {
|
||||||
|
assetManagerStore.setItemList(response)
|
||||||
|
|
||||||
|
if (unsetSelectedItem) {
|
||||||
|
assetManagerStore.setSelectedItem(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveItem() {
|
||||||
|
const itemData = {
|
||||||
|
id: selectedItem.value!.id,
|
||||||
|
name: itemName.value,
|
||||||
|
description: itemDescription.value,
|
||||||
|
itemType: itemType.value,
|
||||||
|
rarity: itemRarity.value,
|
||||||
|
stackable: itemStackable.value,
|
||||||
|
spriteId: itemSpriteId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_ITEM_UPDATE, itemData, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to save item')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshItemList(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedItem, (item: Item | null) => {
|
||||||
|
if (!item) return
|
||||||
|
itemName.value = item.name
|
||||||
|
itemDescription.value = item.description || ''
|
||||||
|
itemType.value = item.itemType
|
||||||
|
itemRarity.value = item.rarity
|
||||||
|
itemStackable.value = item.stackable
|
||||||
|
itemSpriteId.value = item.spriteId
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!selectedItem.value) return
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
|
||||||
|
assetManagerStore.setSpriteList(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
assetManagerStore.setSelectedItem(null)
|
||||||
|
})
|
||||||
|
</script>
|
@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative mb-5 flex items-center gap-x-2.5">
|
||||||
|
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
||||||
|
<label for="create-item" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2.5 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
||||||
|
<button class="p-0 h-5" id="create-item" @click="createNewItem">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="white">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-bind="containerProps" class="flex-1 overflow-y-auto relative p-2.5 rounded-md default-border bg-gray" @scroll="onScroll">
|
||||||
|
<div v-bind="wrapperProps" ref="elementToScroll" class="flex flex-col gap-2.5">
|
||||||
|
<a v-for="{ data: item } in list" :key="item.id" class="relative p-2.5 cursor-pointer block rounded hover:bg-cyan group" :class="{ 'bg-cyan': assetManagerStore.selectedItem?.id === item.id }" @click="assetManagerStore.setSelectedItem(item as Item)">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span class="group-hover:text-white" :class="{ 'text-white': assetManagerStore.selectedItem?.id === item.id }">
|
||||||
|
{{ item.name }}
|
||||||
|
<small class="text-gray-400">({{ item.itemType }})</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="absolute w-12 h-12 bottom-2.5 right-2.5">
|
||||||
|
<button class="fixed min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
||||||
|
<img class="invert w-8 h-8 center-element rotate-180" src="/assets/icons/mapEditor/chevron.svg" alt="" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { Item } from '@/application/types'
|
||||||
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { useVirtualList } from '@vueuse/core'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const assetManagerStore = useAssetManagerStore()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const hasScrolled = ref(false)
|
||||||
|
const elementToScroll = ref()
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
virtualList.value?.scrollTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewItem = () => {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_ITEM_CREATE, {}, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to create new item')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => {
|
||||||
|
assetManagerStore.setItemList(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return assetManagerStore.itemList
|
||||||
|
}
|
||||||
|
return assetManagerStore.itemList.filter((item) => item.name.toLowerCase().includes(searchQuery.value.toLowerCase()) || item.itemType.toLowerCase().includes(searchQuery.value.toLowerCase()))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(filteredItems, {
|
||||||
|
itemHeight: 48
|
||||||
|
})
|
||||||
|
|
||||||
|
const virtualList = ref({ scrollTo })
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
let scrollTop = elementToScroll.value.style.marginTop.replace('px', '')
|
||||||
|
|
||||||
|
if (scrollTop > 80) {
|
||||||
|
hasScrolled.value = true
|
||||||
|
} else if (scrollTop <= 80) {
|
||||||
|
hasScrolled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTop() {
|
||||||
|
virtualList.value?.scrollTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_ITEM_LIST, {}, (response: Item[]) => {
|
||||||
|
assetManagerStore.setItemList(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full overflow-auto">
|
||||||
|
<div class="relative p-2.5 flex flex-col items-center justify-center h-72 rounded-md default-border bg-gray">
|
||||||
|
<img class="max-h-56" :src="`${config.server_endpoint}/textures/map_objects/${selectedMapObject?.id}.png`" :alt="'Object ' + selectedMapObject?.id" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 block">
|
||||||
|
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveObject">
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input v-model="mapObjectName" class="input-field" type="text" name="name" placeholder="Wall #1" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-half">
|
||||||
|
<label for="origin-x">Origin X</label>
|
||||||
|
<input v-model="mapObjectOriginX" class="input-field" type="number" step="any" name="origin-x" placeholder="Origin X" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-half">
|
||||||
|
<label for="origin-y">Origin Y</label>
|
||||||
|
<input v-model="mapObjectOriginY" class="input-field" type="number" step="any" name="origin-y" placeholder="Origin Y" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="tags">Tags</label>
|
||||||
|
<ChipsInput v-model="mapObjectTags" @update:modelValue="mapObjectTags = $event" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="frame-speed">Frame rate</label>
|
||||||
|
<input v-model="mapObjectFrameRate" class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame rate" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-half">
|
||||||
|
<label for="frame-width">Frame width</label>
|
||||||
|
<input v-model="mapObjectFrameWidth" class="input-field" type="number" step="any" name="frame-width" placeholder="Frame width" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-half">
|
||||||
|
<label for="frame-height">Frame height</label>
|
||||||
|
<input v-model="mapObjectFrameHeight" class="input-field" type="number" step="any" name="frame-height" placeholder="Frame height" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<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="removeObject">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import config from '@/application/config'
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { MapObject } from '@/application/types'
|
||||||
|
import ChipsInput from '@/components/forms/ChipsInput.vue'
|
||||||
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const assetManagerStore = useAssetManagerStore()
|
||||||
|
|
||||||
|
const selectedMapObject = computed(() => assetManagerStore.selectedMapObject)
|
||||||
|
|
||||||
|
const mapObjectName = ref('')
|
||||||
|
const mapObjectTags = ref<string[]>([])
|
||||||
|
const mapObjectOriginX = ref(0)
|
||||||
|
const mapObjectOriginY = ref(0)
|
||||||
|
const mapObjectFrameRate = ref(0)
|
||||||
|
const mapObjectFrameWidth = ref(0)
|
||||||
|
const mapObjectFrameHeight = ref(0)
|
||||||
|
|
||||||
|
if (!selectedMapObject.value) {
|
||||||
|
console.error('No map mapObject selected')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMapObject.value) {
|
||||||
|
mapObjectName.value = selectedMapObject.value.name
|
||||||
|
mapObjectTags.value = selectedMapObject.value.tags
|
||||||
|
mapObjectOriginX.value = selectedMapObject.value.originX
|
||||||
|
mapObjectOriginY.value = selectedMapObject.value.originY
|
||||||
|
mapObjectFrameRate.value = selectedMapObject.value.frameRate
|
||||||
|
mapObjectFrameWidth.value = selectedMapObject.value.frameWidth
|
||||||
|
mapObjectFrameHeight.value = selectedMapObject.value.frameHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeObject() {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_REMOVE, { mapObject: selectedMapObject.value?.id }, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to remove mapObject')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshObjectList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshObjectList(unsetSelectedMapObject = true) {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => {
|
||||||
|
assetManagerStore.setMapObjectList(response)
|
||||||
|
|
||||||
|
if (unsetSelectedMapObject) {
|
||||||
|
assetManagerStore.setSelectedMapObject(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveObject() {
|
||||||
|
if (!selectedMapObject.value) {
|
||||||
|
console.error('No mapObject selected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.connection?.emit(
|
||||||
|
'gm:mapObject:update',
|
||||||
|
{
|
||||||
|
id: selectedMapObject.value.id,
|
||||||
|
name: mapObjectName.value,
|
||||||
|
tags: mapObjectTags.value,
|
||||||
|
originX: mapObjectOriginX.value,
|
||||||
|
originY: mapObjectOriginY.value,
|
||||||
|
frameRate: mapObjectFrameRate.value,
|
||||||
|
frameWidth: mapObjectFrameWidth.value,
|
||||||
|
frameHeight: mapObjectFrameHeight.value
|
||||||
|
},
|
||||||
|
(response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to save mapObject')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshObjectList(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedMapObject, (mapObject: MapObject | null) => {
|
||||||
|
if (!mapObject) return
|
||||||
|
mapObjectName.value = mapObject.name
|
||||||
|
mapObjectTags.value = mapObject.tags
|
||||||
|
mapObjectOriginX.value = mapObject.originX
|
||||||
|
mapObjectOriginY.value = mapObject.originY
|
||||||
|
mapObjectFrameRate.value = mapObject.frameRate
|
||||||
|
mapObjectFrameWidth.value = mapObject.frameWidth
|
||||||
|
mapObjectFrameHeight.value = mapObject.frameHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!selectedMapObject.value) return
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
assetManagerStore.setSelectedMapObject(null)
|
||||||
|
})
|
||||||
|
</script>
|
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative mb-5 flex items-center gap-x-2.5">
|
||||||
|
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
||||||
|
<label for="upload-asset" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2.5 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
||||||
|
<input class="hidden" id="upload-asset" ref="objectUploadField" type="file" accept="image/png" multiple @change="handleFileUpload" />
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-bind="containerProps" class="flex-1 overflow-y-auto relative p-2.5 rounded-md default-border bg-gray" @scroll="onScroll">
|
||||||
|
<div v-bind="wrapperProps" ref="elementToScroll" class="flex flex-col gap-2.5">
|
||||||
|
<a v-for="{ data: mapObject } in list" :key="mapObject.id" class="relative p-2.5 cursor-pointer block rounded hover:bg-cyan group" :class="{ 'bg-cyan': assetManagerStore.selectedMapObject?.id === mapObject.id }" @click="assetManagerStore.setSelectedMapObject(mapObject as MapObject)">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="h-7 w-16 max-w-16 flex justify-center">
|
||||||
|
<img class="h-7" :src="`${config.server_endpoint}/textures/map_objects/${mapObject.id}.png`" alt="Object" />
|
||||||
|
</div>
|
||||||
|
<span :class="{ 'text-white': assetManagerStore.selectedMapObject?.id === mapObject.id }">{{ mapObject.name }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="absolute w-12 h-12 bottom-2.5 right-2.5">
|
||||||
|
<button class="fixed min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
||||||
|
<img class="invert w-8 h-8 center-element rotate-180" src="/assets/icons/mapEditor/chevron.svg" alt="" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import config from '@/application/config'
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { MapObject } from '@/application/types'
|
||||||
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { useVirtualList } from '@vueuse/core'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const objectUploadField = ref(null)
|
||||||
|
const assetManagerStore = useAssetManagerStore()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const hasScrolled = ref(false)
|
||||||
|
const elementToScroll = ref()
|
||||||
|
|
||||||
|
const handleFileUpload = (e: Event) => {
|
||||||
|
const files = (e.target as HTMLInputElement).files
|
||||||
|
if (!files) return
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_UPLOAD, files, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
if (config.environment === 'development') console.error('Failed to upload map object')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => {
|
||||||
|
assetManagerStore.setMapObjectList(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
// Trigger a re-render of the virtual list
|
||||||
|
virtualList.value?.scrollTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredObjects = computed(() => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return assetManagerStore.mapObjectList
|
||||||
|
}
|
||||||
|
return assetManagerStore.mapObjectList.filter((object) => object.name.toLowerCase().includes(searchQuery.value.toLowerCase()))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(filteredObjects, {
|
||||||
|
itemHeight: 48
|
||||||
|
})
|
||||||
|
|
||||||
|
const virtualList = ref({ scrollTo })
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
let scrollTop = elementToScroll.value.style.marginTop.replace('px', '')
|
||||||
|
|
||||||
|
if (scrollTop > 80) {
|
||||||
|
hasScrolled.value = true
|
||||||
|
} else if (scrollTop <= 80) {
|
||||||
|
hasScrolled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTop() {
|
||||||
|
virtualList.value?.scrollTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => {
|
||||||
|
assetManagerStore.setMapObjectList(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,163 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full overflow-auto">
|
|
||||||
<div class="relative p-2.5 flex flex-col items-center justify-between h-72">
|
|
||||||
<div class="filler"></div>
|
|
||||||
<img class="max-h-56" :src="`${config.server_endpoint}/assets/objects/${selectedObject?.id}.png`" :alt="'Object ' + selectedObject?.id" />
|
|
||||||
<button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.prevent="removeObject">Remove</button>
|
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</div>
|
|
||||||
<div class="m-2.5 p-2.5 block">
|
|
||||||
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveObject">
|
|
||||||
<div class="form-field-full">
|
|
||||||
<label for="name">Name</label>
|
|
||||||
<input v-model="objectName" class="input-field" type="text" name="name" placeholder="Wall #1" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field-half">
|
|
||||||
<label for="origin-x">Origin X</label>
|
|
||||||
<input v-model="objectOriginX" class="input-field" type="number" step="any" name="origin-x" placeholder="Origin X" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field-half">
|
|
||||||
<label for="origin-y">Origin Y</label>
|
|
||||||
<input v-model="objectOriginY" class="input-field" type="number" step="any" name="origin-y" placeholder="Origin Y" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field-full">
|
|
||||||
<label for="origin-x">Tags</label>
|
|
||||||
<ChipsInput v-model="objectTags" @update:modelValue="objectTags = $event" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field-full">
|
|
||||||
<label for="origin-x">Is animated</label>
|
|
||||||
<select v-model="objectIsAnimated" class="input-field" name="is-animated">
|
|
||||||
<option :value="false">No</option>
|
|
||||||
<option :value="true">Yes</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-field-full">
|
|
||||||
<label for="frame-speed">Frame speed</label>
|
|
||||||
<input v-model="objectFrameSpeed" class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame speed" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field-half">
|
|
||||||
<label for="frame-width">Frame width</label>
|
|
||||||
<input v-model="objectFrameWidth" class="input-field" type="number" step="any" name="frame-width" placeholder="Frame width" />
|
|
||||||
</div>
|
|
||||||
<div class="form-field-half">
|
|
||||||
<label for="frame-height">Frame height</label>
|
|
||||||
<input v-model="objectFrameHeight" class="input-field" type="number" step="any" name="frame-height" placeholder="Frame height" />
|
|
||||||
</div>
|
|
||||||
<button class="btn-cyan px-4 py-1.5 min-w-24" type="submit">Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Object } from '@/types'
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
|
||||||
import { useZoneEditorStore } from '@/stores/zoneEditorStore'
|
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
|
||||||
import config from '@/config'
|
|
||||||
import ChipsInput from '@/components/forms/ChipsInput.vue'
|
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
|
||||||
const assetManagerStore = useAssetManagerStore()
|
|
||||||
const zoneEditorStore = useZoneEditorStore()
|
|
||||||
|
|
||||||
const selectedObject = computed(() => assetManagerStore.selectedObject)
|
|
||||||
|
|
||||||
const objectName = ref('')
|
|
||||||
const objectTags = ref<string[]>([])
|
|
||||||
const objectOriginX = ref(0)
|
|
||||||
const objectOriginY = ref(0)
|
|
||||||
const objectIsAnimated = ref(false)
|
|
||||||
const objectFrameSpeed = ref(0)
|
|
||||||
const objectFrameWidth = ref(0)
|
|
||||||
const objectFrameHeight = ref(0)
|
|
||||||
|
|
||||||
if (!selectedObject.value) {
|
|
||||||
console.error('No object selected')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedObject.value) {
|
|
||||||
objectName.value = selectedObject.value.name
|
|
||||||
objectTags.value = selectedObject.value.tags
|
|
||||||
objectOriginX.value = selectedObject.value.originX
|
|
||||||
objectOriginY.value = selectedObject.value.originY
|
|
||||||
objectIsAnimated.value = selectedObject.value.isAnimated
|
|
||||||
objectFrameSpeed.value = selectedObject.value.frameSpeed
|
|
||||||
objectFrameWidth.value = selectedObject.value.frameWidth
|
|
||||||
objectFrameHeight.value = selectedObject.value.frameHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeObject() {
|
|
||||||
gameStore.connection?.emit('gm:object:remove', { object: selectedObject.value?.id }, (response: boolean) => {
|
|
||||||
if (!response) {
|
|
||||||
console.error('Failed to remove object')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshObjectList()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshObjectList(unsetSelectedObject = true) {
|
|
||||||
gameStore.connection?.emit('gm:object:list', {}, (response: Object[]) => {
|
|
||||||
assetManagerStore.setObjectList(response)
|
|
||||||
|
|
||||||
if (unsetSelectedObject) {
|
|
||||||
assetManagerStore.setSelectedObject(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zoneEditorStore.active) {
|
|
||||||
zoneEditorStore.setObjectList(response)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveObject() {
|
|
||||||
if (!selectedObject.value) {
|
|
||||||
console.error('No object selected')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
gameStore.connection?.emit(
|
|
||||||
'gm:object:update',
|
|
||||||
{
|
|
||||||
id: selectedObject.value.id,
|
|
||||||
name: objectName.value,
|
|
||||||
tags: objectTags.value,
|
|
||||||
originX: objectOriginX.value,
|
|
||||||
originY: objectOriginY.value,
|
|
||||||
isAnimated: objectIsAnimated.value,
|
|
||||||
frameSpeed: objectFrameSpeed.value,
|
|
||||||
frameWidth: objectFrameWidth.value,
|
|
||||||
frameHeight: objectFrameHeight.value
|
|
||||||
},
|
|
||||||
(response: boolean) => {
|
|
||||||
if (!response) {
|
|
||||||
console.error('Failed to save object')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshObjectList(false)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selectedObject, (object: Object | null) => {
|
|
||||||
if (!object) return
|
|
||||||
objectName.value = object.name
|
|
||||||
objectTags.value = object.tags
|
|
||||||
objectOriginX.value = object.originX
|
|
||||||
objectOriginY.value = object.originY
|
|
||||||
objectIsAnimated.value = object.isAnimated
|
|
||||||
objectFrameSpeed.value = object.frameSpeed
|
|
||||||
objectFrameWidth.value = object.frameWidth
|
|
||||||
objectFrameHeight.value = object.frameHeight
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!selectedObject.value) return
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
assetManagerStore.setSelectedObject(null)
|
|
||||||
})
|
|
||||||
</script>
|
|
@ -1,99 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative p-2.5 flex items-center gap-x-2.5">
|
|
||||||
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
|
||||||
<label for="upload-asset" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
|
||||||
<input class="hidden" id="upload-asset" ref="objectUploadField" type="file" accept="image/png" multiple @change="handleFileUpload" />
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</div>
|
|
||||||
<div v-bind="containerProps" class="overflow-y-auto relative" @scroll="onScroll">
|
|
||||||
<div v-bind="wrapperProps" ref="elementToScroll">
|
|
||||||
<a v-for="{ data: object } in list" :key="object.id" class="relative p-2.5 cursor-pointer block" :class="{ 'bg-cyan/80': assetManagerStore.selectedObject?.id === object.id }" @click="assetManagerStore.setSelectedObject(object as Object)">
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<div class="h-7 w-16 max-w-16 flex justify-center">
|
|
||||||
<img class="h-7" :src="`${config.server_endpoint}/assets/objects/${object.id}.png`" alt="Object" />
|
|
||||||
</div>
|
|
||||||
<span :class="{ 'text-white': assetManagerStore.selectedObject?.id === object.id }">{{ object.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<button class="left-[calc(50%_-_60px)] fixed bottom-2.5 min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
|
||||||
<img class="absolute invert w-8 h-8 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rotate-180" src="/assets/icons/zoneEditor/chevron.svg" alt="" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import config from '@/config'
|
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
|
||||||
import { onMounted, ref, computed } from 'vue'
|
|
||||||
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
|
||||||
import type { Object } from '@/types'
|
|
||||||
import { useVirtualList } from '@vueuse/core'
|
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
|
||||||
const objectUploadField = ref(null)
|
|
||||||
const assetManagerStore = useAssetManagerStore()
|
|
||||||
|
|
||||||
const searchQuery = ref('')
|
|
||||||
|
|
||||||
const hasScrolled = ref(false)
|
|
||||||
const elementToScroll = ref()
|
|
||||||
|
|
||||||
const handleFileUpload = (e: Event) => {
|
|
||||||
const files = (e.target as HTMLInputElement).files
|
|
||||||
if (!files) return
|
|
||||||
gameStore.connection?.emit('gm:object:upload', files, (response: boolean) => {
|
|
||||||
if (!response) {
|
|
||||||
if (config.development) console.error('Failed to upload object')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
gameStore.connection?.emit('gm:object:list', {}, (response: Object[]) => {
|
|
||||||
assetManagerStore.setObjectList(response)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
// Trigger a re-render of the virtual list
|
|
||||||
virtualList.value?.scrollTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredObjects = computed(() => {
|
|
||||||
if (!searchQuery.value) {
|
|
||||||
return assetManagerStore.objectList
|
|
||||||
}
|
|
||||||
return assetManagerStore.objectList.filter((object) => object.name.toLowerCase().includes(searchQuery.value.toLowerCase()))
|
|
||||||
})
|
|
||||||
|
|
||||||
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(filteredObjects, {
|
|
||||||
itemHeight: 48
|
|
||||||
})
|
|
||||||
|
|
||||||
const virtualList = ref({ scrollTo })
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
let scrollTop = elementToScroll.value.style.marginTop.replace('px', '')
|
|
||||||
|
|
||||||
if (scrollTop > 80) {
|
|
||||||
hasScrolled.value = true
|
|
||||||
} else if (scrollTop <= 80) {
|
|
||||||
hasScrolled.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toTop() {
|
|
||||||
virtualList.value?.scrollTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
gameStore.connection?.emit('gm:object:list', {}, (response: Object[]) => {
|
|
||||||
assetManagerStore.setObjectList(response)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full overflow-auto">
|
<div class="h-full overflow-auto">
|
||||||
<div class="relative p-4 flex flex-col">
|
<div class="relative flex flex-col">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2 p-2.5 rounded-md default-border bg-gray">
|
||||||
<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" />
|
||||||
@ -10,16 +10,23 @@
|
|||||||
<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>
|
||||||
<div class="w-[calc(100%_+_32px)] absolute left-[-15px] bottom-0 h-px bg-gray-500"></div>
|
<button class="btn-indigo px-4 py-2 flex-1 sm:flex-none" type="button" @click.prevent="copySprite">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn-cyan py-2 my-4" type="button" @click.prevent="addNewImage">New action</button>
|
<button class="btn-cyan py-2 my-4" type="button" @click.prevent="addNewImage">New action</button>
|
||||||
<Accordion v-for="action in spriteActions" :key="action.id">
|
<Accordion v-for="action in spriteActions" :key="action.id">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex items-center">
|
||||||
{{ action.action }}
|
{{ action.action }}
|
||||||
<button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.prevent="() => spriteActions.splice(spriteActions.indexOf(action), 1)">Delete</button>
|
<div class="ml-auto space-x-2">
|
||||||
|
<button class="btn-cyan px-4 py-1.5 min-w-24" type="button" @click.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>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
@ -36,42 +43,40 @@
|
|||||||
<label for="origin-y">Origin Y</label>
|
<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" />
|
<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-half">
|
<div class="form-field-full">
|
||||||
<label for="is-animated">Is animated</label>
|
<label for="frame-speed">Frame rate</label>
|
||||||
<select v-model="action.isAnimated" class="input-field" name="is-animated">
|
<input v-model.number="action.frameRate" class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame rate" />
|
||||||
<option :value="false">No</option>
|
|
||||||
<option :value="true">Yes</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-field-half" v-if="action.isAnimated">
|
|
||||||
<label for="is-looping">Is looping</label>
|
|
||||||
<select v-model="action.isLooping" class="input-field" name="is-looping">
|
|
||||||
<option :value="false">No</option>
|
|
||||||
<option :value="true">Yes</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-field-full" v-if="action.isAnimated">
|
|
||||||
<label for="frame-speed">Frame speed</label>
|
|
||||||
<input v-model.number="action.frameSpeed" class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame speed" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field-full">
|
<div class="form-field-full">
|
||||||
<SpriteActionsInput v-model="action.sprites" />
|
<SpriteActionsInput v-model="action.sprites" @tempOffsetChange="(index, offset) => handleTempOffsetChange(action, index, offset)" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
<SpritePreview
|
||||||
|
v-if="selectedAction"
|
||||||
|
:sprites="selectedAction.sprites"
|
||||||
|
:frame-rate="selectedAction.frameRate"
|
||||||
|
:is-modal-open="isModalOpen"
|
||||||
|
:temp-offset-index="tempOffsetData.index"
|
||||||
|
:temp-offset="tempOffsetData.offset"
|
||||||
|
@update:frame-rate="updateFrameRate"
|
||||||
|
@update:is-modal-open="isModalOpen = $event"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Sprite, SpriteAction } from '@/types'
|
import { SocketEvent } from '@/application/enums'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import type { Sprite, SpriteAction, UUID } from '@/application/types'
|
||||||
|
import { uuidv4 } from '@/application/utilities'
|
||||||
|
import SpriteActionsInput from '@/components/gameMaster/assetManager/partials/sprite/partials/SpriteImagesInput.vue'
|
||||||
|
import SpritePreview from '@/components/gameMaster/assetManager/partials/sprite/partials/SpritePreview.vue'
|
||||||
|
import Accordion from '@/components/utilities/Accordion.vue'
|
||||||
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import Accordion from '@/components/utilities/Accordion.vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import SpriteActionsInput from '@/components/gameMaster/assetManager/partials/sprite/partials/SpriteImagesInput.vue'
|
|
||||||
import { uuidv4 } from '@/utilities'
|
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const assetManagerStore = useAssetManagerStore()
|
const assetManagerStore = useAssetManagerStore()
|
||||||
@ -80,6 +85,8 @@ const selectedSprite = computed(() => assetManagerStore.selectedSprite)
|
|||||||
|
|
||||||
const spriteName = ref('')
|
const spriteName = ref('')
|
||||||
const spriteActions = ref<SpriteAction[]>([])
|
const spriteActions = ref<SpriteAction[]>([])
|
||||||
|
const isModalOpen = ref(false)
|
||||||
|
const selectedAction = ref<SpriteAction | null>(null)
|
||||||
|
|
||||||
if (!selectedSprite.value) {
|
if (!selectedSprite.value) {
|
||||||
console.error('No sprite selected')
|
console.error('No sprite selected')
|
||||||
@ -87,11 +94,11 @@ if (!selectedSprite.value) {
|
|||||||
|
|
||||||
if (selectedSprite.value) {
|
if (selectedSprite.value) {
|
||||||
spriteName.value = selectedSprite.value.name
|
spriteName.value = selectedSprite.value.name
|
||||||
spriteActions.value = selectedSprite.value.spriteActions
|
spriteActions.value = sortSpriteActions(selectedSprite.value.spriteActions)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSprite() {
|
function deleteSprite() {
|
||||||
gameStore.connection?.emit('gm:sprite:delete', { id: selectedSprite.value?.id }, (response: boolean) => {
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_DELETE, { id: selectedSprite.value?.id }, (response: boolean) => {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
console.error('Failed to delete sprite')
|
console.error('Failed to delete sprite')
|
||||||
return
|
return
|
||||||
@ -100,8 +107,18 @@ function deleteSprite() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copySprite() {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_COPY, { id: selectedSprite.value?.id }, (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
console.error('Failed to copy sprite')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshSpriteList(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function refreshSpriteList(unsetSelectedSprite = true) {
|
function refreshSpriteList(unsetSelectedSprite = true) {
|
||||||
gameStore.connection?.emit('gm:sprite:list', {}, (response: Sprite[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
|
||||||
assetManagerStore.setSpriteList(response)
|
assetManagerStore.setSpriteList(response)
|
||||||
|
|
||||||
if (unsetSelectedSprite) {
|
if (unsetSelectedSprite) {
|
||||||
@ -126,16 +143,14 @@ function saveSprite() {
|
|||||||
sprites: action.sprites,
|
sprites: action.sprites,
|
||||||
originX: action.originX,
|
originX: action.originX,
|
||||||
originY: action.originY,
|
originY: action.originY,
|
||||||
isAnimated: action.isAnimated,
|
frameRate: action.frameRate,
|
||||||
isLooping: action.isLooping,
|
|
||||||
frameSpeed: action.frameSpeed,
|
|
||||||
frameWidth: action.frameWidth,
|
frameWidth: action.frameWidth,
|
||||||
frameHeight: action.frameHeight
|
frameHeight: action.frameHeight
|
||||||
}
|
}
|
||||||
}) ?? []
|
}) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
gameStore.connection?.emit('gm:sprite:update', updatedSprite, (response: boolean) => {
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_UPDATE, updatedSprite, (response: boolean) => {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
console.error('Failed to save sprite')
|
console.error('Failed to save sprite')
|
||||||
return
|
return
|
||||||
@ -148,16 +163,13 @@ function addNewImage() {
|
|||||||
if (!selectedSprite.value) return
|
if (!selectedSprite.value) return
|
||||||
|
|
||||||
const newImage: SpriteAction = {
|
const newImage: SpriteAction = {
|
||||||
id: uuidv4(), // Temporary ID, should be replaced by server-generated ID
|
id: uuidv4(),
|
||||||
spriteId: selectedSprite.value.id,
|
sprite: selectedSprite.value.id,
|
||||||
sprite: selectedSprite.value,
|
|
||||||
action: 'new_action',
|
action: 'new_action',
|
||||||
sprites: [],
|
sprites: [],
|
||||||
originX: 0,
|
originX: 0,
|
||||||
originY: 0,
|
originY: 0,
|
||||||
isAnimated: false,
|
frameRate: 0,
|
||||||
isLooping: false,
|
|
||||||
frameSpeed: 0,
|
|
||||||
frameWidth: 0,
|
frameWidth: 0,
|
||||||
frameHeight: 0
|
frameHeight: 0
|
||||||
}
|
}
|
||||||
@ -166,13 +178,46 @@ function addNewImage() {
|
|||||||
spriteActions.value = []
|
spriteActions.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
spriteActions.value.push(newImage)
|
spriteActions.value = sortSpriteActions([...spriteActions.value, newImage])
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSpriteActions(actions: SpriteAction[]): SpriteAction[] {
|
||||||
|
if (!actions) return []
|
||||||
|
return [...actions].sort((a, b) => a.action.localeCompare(b.action))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreviewModal(action: SpriteAction) {
|
||||||
|
selectedAction.value = action
|
||||||
|
isModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFrameRate(value: number) {
|
||||||
|
if (selectedAction.value) {
|
||||||
|
selectedAction.value.frameRate = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempOffsetData = ref<{ index: number | undefined; offset: { x: number; y: number } | undefined }>({
|
||||||
|
index: undefined,
|
||||||
|
offset: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleTempOffsetChange(action: SpriteAction, index: number, offset: { x: number; y: number }) {
|
||||||
|
if (selectedAction.value === action) {
|
||||||
|
tempOffsetData.value = { index, 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 = sprite.spriteActions
|
spriteActions.value = sortSpriteActions(sprite.spriteActions)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isModalOpen, (newValue) => {
|
||||||
|
if (!newValue) {
|
||||||
|
selectedAction.value = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
@ -1,35 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative p-2.5 flex items-center gap-x-2.5">
|
<div class="relative mb-5 flex items-center gap-x-2.5">
|
||||||
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
||||||
<button @click.prevent="newButtonClickHandler" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
<button @click.prevent="newButtonClickHandler" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2.5 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-bind="containerProps" class="overflow-y-auto relative" @scroll="onScroll">
|
<div v-bind="containerProps" class="flex-1 overflow-y-auto relative p-2.5 rounded-md default-border bg-gray" @scroll="onScroll">
|
||||||
<div v-bind="wrapperProps" ref="elementToScroll">
|
<div v-bind="wrapperProps" ref="elementToScroll" class="flex flex-col gap-2.5">
|
||||||
<a v-for="{ data: sprite } in list" :key="sprite.id" class="relative p-2.5 cursor-pointer block" :class="{ 'bg-cyan/80': assetManagerStore.selectedSprite?.id === sprite.id }" @click="assetManagerStore.setSelectedSprite(sprite as Sprite)">
|
<a v-for="{ data: sprite } in list" :key="sprite.id" class="relative p-2.5 cursor-pointer block rounded hover:bg-cyan group" :class="{ 'bg-cyan': assetManagerStore.selectedSprite?.id === sprite.id }" @click="assetManagerStore.setSelectedSprite(sprite as Sprite)">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<span :class="{ 'text-white': assetManagerStore.selectedSprite?.id === sprite.id }">{{ sprite.name }}</span>
|
<span :class="{ 'text-white': assetManagerStore.selectedSprite?.id === sprite.id }">{{ sprite.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button class="left-[calc(50%_-_60px)] fixed bottom-2.5 min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
<div class="absolute w-12 h-12 bottom-2.5 right-2.5">
|
||||||
<img class="absolute invert w-8 h-8 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rotate-180" src="/assets/icons/zoneEditor/chevron.svg" alt="" />
|
<button class="fixed min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
||||||
|
<img class="invert w-8 h-8 center-element rotate-180" src="/assets/icons/mapEditor/chevron.svg" alt="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import config from '@/config'
|
import config from '@/application/config'
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { SocketEvent } from '@/application/enums'
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import type { Sprite } from '@/application/types'
|
||||||
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import { useVirtualList } from '@vueuse/core'
|
import { useVirtualList } from '@vueuse/core'
|
||||||
import type { Sprite } from '@/types'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const assetManagerStore = useAssetManagerStore()
|
const assetManagerStore = useAssetManagerStore()
|
||||||
@ -40,13 +41,13 @@ const hasScrolled = ref(false)
|
|||||||
const elementToScroll = ref()
|
const elementToScroll = ref()
|
||||||
|
|
||||||
function newButtonClickHandler() {
|
function newButtonClickHandler() {
|
||||||
gameStore.connection?.emit('gm:sprite:create', {}, (response: boolean) => {
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_CREATE, {}, (response: boolean) => {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
if (config.development) console.error('Failed to create new sprite')
|
if (config.environment === 'development') console.error('Failed to create new sprite')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gameStore.connection?.emit('gm:sprite:list', {}, (response: Sprite[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
|
||||||
assetManagerStore.setSpriteList(response)
|
assetManagerStore.setSpriteList(response)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -85,7 +86,7 @@ function toTop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
gameStore.connection?.emit('gm:sprite:list', {}, (response: Sprite[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
|
||||||
assetManagerStore.setSpriteList(response)
|
assetManagerStore.setSpriteList(response)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,14 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap gap-3">
|
<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-50 bg-opacity-50 rounded text-center relative group cursor-move" draggable="true" @dragstart="dragStart($event, index)" @dragover.prevent @dragenter.prevent @drop="drop($event, index)">
|
<div v-for="(image, index) in modelValue" :key="index" class="h-20 w-20 p-4 bg-gray-300 bg-opacity-50 rounded text-center relative group cursor-move" draggable="true" @dragstart="dragStart($event, index)" @dragover.prevent @dragenter.prevent @drop="drop($event, index)">
|
||||||
<img :src="image" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" />
|
<img :src="image.url" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" @load="updateImageDimensions($event, index)" />
|
||||||
<button @click.stop="deleteImage(index)" class="absolute top-1 right-1 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">
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div class="h-20 w-20 p-4 bg-gray-100 bg-opacity-50 rounded justify-center items-center flex hover:cursor-pointer" @click="triggerFileInput" @drop.prevent="onDrop" @dragover.prevent>
|
|
||||||
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -18,10 +50,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import Modal from '@/components/utilities/Modal.vue'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface SpriteImage {
|
||||||
|
url: string
|
||||||
|
offset: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: string[]
|
modelValue: SpriteImage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -29,11 +70,15 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: string[]): void
|
(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 fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const draggedIndex = ref<number | null>(null)
|
const draggedIndex = ref<number | null>(null)
|
||||||
|
const selectedImageIndex = ref<number | null>(null)
|
||||||
|
const tempOffset = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
const triggerFileInput = () => {
|
const triggerFileInput = () => {
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
@ -54,19 +99,25 @@ const onDrop = (event: DragEvent) => {
|
|||||||
|
|
||||||
const handleFiles = (files: FileList) => {
|
const handleFiles = (files: FileList) => {
|
||||||
Array.from(files).forEach((file) => {
|
Array.from(files).forEach((file) => {
|
||||||
if (file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
if (typeof e.target?.result === 'string') {
|
if (typeof e.target?.result === 'string') {
|
||||||
updateImages([...props.modelValue, e.target.result])
|
const newImage: SpriteImage = {
|
||||||
|
url: e.target.result,
|
||||||
|
offset: { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
updateImages([...props.modelValue, newImage])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateImages = (newImages: string[]) => {
|
const updateImages = (newImages: SpriteImage[]) => {
|
||||||
emit('update:modelValue', newImages)
|
emit('update:modelValue', newImages)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,4 +145,41 @@ const drop = (event: DragEvent, dropIndex: number) => {
|
|||||||
}
|
}
|
||||||
draggedIndex.value = null
|
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>
|
</script>
|
||||||
|
@ -0,0 +1,151 @@
|
|||||||
|
<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>
|
@ -1,12 +1,9 @@
|
|||||||
<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-between h-72">
|
<div class="relative p-2.5 flex flex-col items-center justify-center h-72 rounded-md default-border bg-gray">
|
||||||
<div class="filler"></div>
|
<img class="max-h-72" :src="`${config.server_endpoint}/textures/tiles/${selectedTile?.id}.png`" :alt="'Tile ' + selectedTile?.id" />
|
||||||
<img class="max-h-72" :src="`${config.server_endpoint}/assets/tiles/${selectedTile?.id}.png`" :alt="'Tile ' + selectedTile?.id" />
|
|
||||||
<button class="btn-red px-4 py-1.5 min-w-24" type="button" @click.prevent="deleteTile">Delete</button>
|
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="m-2.5 p-2.5 block">
|
<div class="mt-5 block">
|
||||||
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveTile">
|
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveTile">
|
||||||
<div class="form-field-full">
|
<div class="form-field-full">
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
@ -16,24 +13,28 @@
|
|||||||
<label for="origin-x">Tags</label>
|
<label for="origin-x">Tags</label>
|
||||||
<ChipsInput v-model="tileTags" @update:modelValue="tileTags = $event" />
|
<ChipsInput v-model="tileTags" @update:modelValue="tileTags = $event" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
<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="deleteTile">Delete</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Tile } from '@/types'
|
import config from '@/application/config'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
|
import { SocketEvent } from '@/application/enums'
|
||||||
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
import type { Tile } from '@/application/types'
|
||||||
import { useZoneEditorStore } from '@/stores/zoneEditorStore'
|
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
|
||||||
import config from '@/config'
|
|
||||||
import ChipsInput from '@/components/forms/ChipsInput.vue'
|
import ChipsInput from '@/components/forms/ChipsInput.vue'
|
||||||
|
import { TileStorage } from '@/storage/storages'
|
||||||
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const assetManagerStore = useAssetManagerStore()
|
const assetManagerStore = useAssetManagerStore()
|
||||||
const zoneEditorStore = useZoneEditorStore()
|
const tileStorage = new TileStorage()
|
||||||
|
|
||||||
const selectedTile = computed(() => assetManagerStore.selectedTile)
|
const selectedTile = computed(() => assetManagerStore.selectedTile)
|
||||||
|
|
||||||
@ -55,27 +56,24 @@ watch(selectedTile, (tile: Tile | null) => {
|
|||||||
tileTags.value = tile.tags
|
tileTags.value = tile.tags
|
||||||
})
|
})
|
||||||
|
|
||||||
function deleteTile() {
|
async function deleteTile() {
|
||||||
gameStore.connection?.emit('gm:tile:delete', { id: selectedTile.value?.id }, (response: boolean) => {
|
gameStore.connection?.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()
|
refreshTileList()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshTileList(unsetSelectedTile = true) {
|
function refreshTileList(unsetSelectedTile = true) {
|
||||||
gameStore.connection?.emit('gm:tile:list', {}, (response: Tile[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => {
|
||||||
assetManagerStore.setTileList(response)
|
assetManagerStore.setTileList(response)
|
||||||
|
|
||||||
if (unsetSelectedTile) {
|
if (unsetSelectedTile) {
|
||||||
assetManagerStore.setSelectedTile(null)
|
assetManagerStore.setSelectedTile(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zoneEditorStore.active) {
|
|
||||||
zoneEditorStore.setTileList(response)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,39 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative p-2.5 flex items-center gap-x-2.5">
|
<div class="relative mb-5 flex items-center gap-x-2.5">
|
||||||
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
<input v-model="searchQuery" class="input-field flex-grow" placeholder="Search..." @input="handleSearch" />
|
||||||
<label for="upload-asset" class="bg-cyan text-white border border-solid border-white/25 rounded drop-shadow-20 p-2 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
<label for="upload-asset" class="bg-cyan text-white border border-solid border-white/25 rounded p-2.5 inline-flex items-center justify-center hover:bg-cyan-800 hover:cursor-pointer">
|
||||||
<input class="hidden" id="upload-asset" ref="tileUploadField" type="file" accept="image/png" multiple @change="handleFileUpload" />
|
<input class="hidden" id="upload-asset" ref="tileUploadField" type="file" accept="image/png" multiple @change="handleFileUpload" />
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-bind="containerProps" class="overflow-y-auto relative" @scroll="onScroll">
|
<div v-bind="containerProps" class="flex-1 overflow-y-auto relative p-2.5 rounded-md default-border bg-gray" @scroll="onScroll">
|
||||||
<div v-bind="wrapperProps" ref="elementToScroll">
|
<div v-bind="wrapperProps" ref="elementToScroll" class="flex flex-col gap-2.5">
|
||||||
<a v-for="{ data: tile } in list" :key="tile.id" class="relative p-2.5 cursor-pointer block" :class="{ 'bg-cyan/80': assetManagerStore.selectedTile?.id === tile.id }" @click="assetManagerStore.setSelectedTile(tile)">
|
<a v-for="{ data: tile } in list" :key="tile.id" class="relative p-2.5 cursor-pointer block rounded hover:bg-cyan group" :class="{ 'bg-cyan': assetManagerStore.selectedTile?.id === tile.id }" @click="assetManagerStore.setSelectedTile(tile)">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<div class="h-7 w-16 max-w-16 flex justify-center">
|
<div class="h-7 w-16 max-w-16 flex justify-center">
|
||||||
<img class="h-7" :src="`${config.server_endpoint}/assets/tiles/${tile.id}.png`" alt="Tile" />
|
<img class="h-7" :src="`${config.server_endpoint}/textures/tiles/${tile.id}.png`" alt="Tile" />
|
||||||
</div>
|
</div>
|
||||||
<span :class="{ 'text-white': assetManagerStore.selectedTile?.id === tile.id }">{{ tile.name }}</span>
|
<span class="group-hover:text-white" :class="{ 'text-white': assetManagerStore.selectedTile?.id === tile.id }">{{ tile.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button class="left-[calc(50%_-_60px)] fixed bottom-2.5 min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
<div class="absolute w-12 h-12 bottom-2.5 right-2.5">
|
||||||
<img class="absolute invert w-8 h-8 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rotate-180" src="/assets/icons/zoneEditor/chevron.svg" alt="" />
|
<button class="fixed min-w-[unset] w-12 h-12 rounded-md bg-cyan p-0 hover:bg-cyan-800" v-show="hasScrolled" @click="toTop">
|
||||||
|
<img class="invert w-8 h-8 center-element rotate-180" src="/assets/icons/mapEditor/chevron.svg" alt="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import config from '@/config'
|
import config from '@/application/config'
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { SocketEvent } from '@/application/enums'
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import type { Tile } from '@/application/types'
|
||||||
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
import { useAssetManagerStore } from '@/stores/assetManagerStore'
|
||||||
import type { Tile } from '@/types'
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import { useVirtualList } from '@vueuse/core'
|
import { useVirtualList } from '@vueuse/core'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const tileUploadField = ref(null)
|
const tileUploadField = ref(null)
|
||||||
@ -47,13 +48,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('gm:tile:upload', files, (response: boolean) => {
|
gameStore.connection?.emit(SocketEvent.GM_TILE_UPLOAD, files, (response: boolean) => {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
if (config.development) console.error('Failed to upload tile')
|
if (config.environment === 'development') console.error('Failed to upload tile')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gameStore.connection?.emit('gm:tile:list', {}, (response: Tile[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => {
|
||||||
assetManagerStore.setTileList(response)
|
assetManagerStore.setTileList(response)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -92,7 +93,7 @@ function toTop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
gameStore.connection?.emit('gm:tile:list', {}, (response: Tile[]) => {
|
gameStore.connection?.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => {
|
||||||
assetManagerStore.setTileList(response)
|
assetManagerStore.setTileList(response)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
240
src/components/gameMaster/mapEditor/Map.vue
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<MapTiles ref="mapTiles" @createCommand="addCommand" v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer />
|
||||||
|
<PlacedMapObjects ref="mapObjects" @update="updateMapObjects" @updateAndCommit="updateAndCommit" @pauseObjectTracking="pause" @resumeObjectTracking="resume" v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer />
|
||||||
|
<MapEventTiles ref="eventTiles" @createCommand="addCommand" v-if="tileMap" :tileMap />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MapEventTile, Map as MapT, PlacedMapObject as PlacedMapObjectT } from '@/application/types'
|
||||||
|
import MapEventTiles from '@/components/gameMaster/mapEditor/mapPartials/MapEventTiles.vue'
|
||||||
|
import MapTiles from '@/components/gameMaster/mapEditor/mapPartials/MapTiles.vue'
|
||||||
|
import PlacedMapObjects from '@/components/gameMaster/mapEditor/mapPartials/PlacedMapObjects.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { cloneArray, createTileArray, createTileLayer, createTileMap, placeTiles } from '@/services/mapService'
|
||||||
|
import { TileStorage } from '@/storage/storages'
|
||||||
|
import { useManualRefHistory, useRefHistory } from '@vueuse/core'
|
||||||
|
import { useScene } from 'phavuer'
|
||||||
|
import { onBeforeUnmount, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||||
|
|
||||||
|
const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
|
||||||
|
const tileMapLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
|
||||||
|
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
const scene = useScene()
|
||||||
|
|
||||||
|
const mapTiles = useTemplateRef('mapTiles')
|
||||||
|
const mapObjects = useTemplateRef('mapObjects')
|
||||||
|
const eventTiles = useTemplateRef('eventTiles')
|
||||||
|
|
||||||
|
//Record of commands
|
||||||
|
let commandStack: (EditorCommand | number)[] = []
|
||||||
|
let commandIndex = ref(0)
|
||||||
|
|
||||||
|
let originTiles: string[][] = []
|
||||||
|
let originEventTiles: MapEventTile[] = []
|
||||||
|
let originObjects = ref<PlacedMapObjectT[]>(mapEditor.currentMap.value.placedMapObjects)
|
||||||
|
|
||||||
|
const { undo, redo, commit, pause, resume, canUndo, canRedo } = useRefHistory(originObjects, { deep: true, capacity: 9 })
|
||||||
|
|
||||||
|
//Command Pattern basic interface, extended to store what elements have been changed by each edit
|
||||||
|
export interface EditorCommand {
|
||||||
|
apply: (elements: any[]) => any[]
|
||||||
|
type: 'tile' | 'map_object' | 'event_tile'
|
||||||
|
operation: 'draw' | 'erase' | 'clear'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCommands(tiles: any[], ...commands: EditorCommand[]): any[] {
|
||||||
|
let tileVersion = cloneArray(tiles)
|
||||||
|
for (let command of commands) {
|
||||||
|
tileVersion = command.apply(tileVersion)
|
||||||
|
}
|
||||||
|
return tileVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mapEditor.shouldClearTiles.value,
|
||||||
|
(shouldClear) => {
|
||||||
|
if (shouldClear && mapEditor.currentMap.value) {
|
||||||
|
mapTiles.value!.clearTiles()
|
||||||
|
eventTiles.value!.clearTiles()
|
||||||
|
mapEditor.currentMap.value.placedMapObjects = []
|
||||||
|
updateAndCommit(mapEditor.currentMap.value)
|
||||||
|
mapEditor.resetClearTilesFlag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function update(commands: (EditorCommand | number)[]) {
|
||||||
|
if (!mapEditor.currentMap.value) return
|
||||||
|
|
||||||
|
if (commandStack.length >= 9) {
|
||||||
|
if (typeof commandStack[0] !== 'number') {
|
||||||
|
const base = commandStack.shift() as EditorCommand
|
||||||
|
if (base.operation !== 'clear') {
|
||||||
|
switch (base.type) {
|
||||||
|
case 'tile':
|
||||||
|
originTiles = base.apply(originTiles) as string[][]
|
||||||
|
break
|
||||||
|
case 'event_tile':
|
||||||
|
originEventTiles = base.apply(originEventTiles) as MapEventTile[]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
commandStack.shift()
|
||||||
|
}
|
||||||
|
} else if (typeof commandStack[0] === 'number') {
|
||||||
|
commandStack.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tileCommands = commands.filter((item) => typeof item !== 'number' && item.type === 'tile') as EditorCommand[]
|
||||||
|
let eventTileCommands = commands.filter((item) => typeof item !== 'number' && item.type === 'event_tile') as EditorCommand[]
|
||||||
|
|
||||||
|
let modifiedTiles = applyCommands(originTiles, ...tileCommands)
|
||||||
|
placeTiles(tileMap.value!, tileMapLayer.value!, modifiedTiles)
|
||||||
|
|
||||||
|
let eventTiles = applyCommands(originEventTiles, ...eventTileCommands)
|
||||||
|
|
||||||
|
mapEditor.currentMap.value.tiles = modifiedTiles
|
||||||
|
mapEditor.currentMap.value.mapEventTiles = eventTiles
|
||||||
|
mapEditor.currentMap.value.placedMapObjects = originObjects.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMapObjects(map: MapT) {
|
||||||
|
originObjects.value = map.placedMapObjects
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAndCommit(map?: MapT) {
|
||||||
|
commandStack = commandStack.slice(0, commandIndex.value)
|
||||||
|
if (map) updateMapObjects(map)
|
||||||
|
commit()
|
||||||
|
commandStack.push(0)
|
||||||
|
commandIndex.value = commandStack.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCommand(command: EditorCommand) {
|
||||||
|
commandStack = commandStack.slice(0, commandIndex.value)
|
||||||
|
commandStack.push(command)
|
||||||
|
commandIndex.value = commandStack.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoEdit() {
|
||||||
|
if (commandIndex.value > 0) {
|
||||||
|
if (typeof commandStack[--commandIndex.value] === 'number' && canUndo) {
|
||||||
|
undo()
|
||||||
|
}
|
||||||
|
update(commandStack.slice(0, commandIndex.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redoEdit() {
|
||||||
|
if (commandIndex.value <= 9 && commandIndex.value < commandStack.length) {
|
||||||
|
if (typeof commandStack[commandIndex.value++] === 'number' && canRedo) {
|
||||||
|
redo()
|
||||||
|
}
|
||||||
|
update(commandStack.slice(0, commandIndex.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(pointer: Phaser.Input.Pointer) {
|
||||||
|
if (!mapTiles.value || !mapObjects.value || !eventTiles.value) return
|
||||||
|
|
||||||
|
// Check if left mouse button is pressed
|
||||||
|
if (!pointer.isDown) return
|
||||||
|
|
||||||
|
// Check if shift is not pressed, this means we are moving the camera
|
||||||
|
if (pointer.event.shiftKey) return
|
||||||
|
|
||||||
|
// Check if draw mode is tile
|
||||||
|
switch (mapEditor.drawMode.value) {
|
||||||
|
case 'tile':
|
||||||
|
mapTiles.value.handlePointer(pointer)
|
||||||
|
break
|
||||||
|
case 'map_object':
|
||||||
|
mapObjects.value.handlePointer(pointer)
|
||||||
|
break
|
||||||
|
case 'teleport':
|
||||||
|
eventTiles.value.handlePointer(pointer)
|
||||||
|
break
|
||||||
|
case 'blocking tile':
|
||||||
|
eventTiles.value.handlePointer(pointer)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
//CTRL+Y
|
||||||
|
if (event.key === 'y' && event.ctrlKey) {
|
||||||
|
redoEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
//CTRL+Z
|
||||||
|
if (event.key === 'z' && event.ctrlKey) {
|
||||||
|
undoEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(pointer: Phaser.Input.Pointer) {
|
||||||
|
if (mapEditor.inputMode.value === 'hold' && pointer.isDown) {
|
||||||
|
handlePointerDown(pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(pointer: Phaser.Input.Pointer) {
|
||||||
|
switch (mapEditor.drawMode.value) {
|
||||||
|
case 'tile':
|
||||||
|
mapTiles.value!.finalizeCommand()
|
||||||
|
break
|
||||||
|
case 'map_object':
|
||||||
|
if (mapEditor.tool.value === 'pencil' || mapEditor.tool.value === 'eraser') {
|
||||||
|
resume()
|
||||||
|
updateAndCommit()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'teleport':
|
||||||
|
eventTiles.value!.finalizeCommand()
|
||||||
|
break
|
||||||
|
case 'blocking tile':
|
||||||
|
eventTiles.value!.finalizeCommand()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
let mapValue = mapEditor.currentMap.value
|
||||||
|
if (!mapValue) return
|
||||||
|
|
||||||
|
//Clone
|
||||||
|
originTiles = cloneArray(mapValue.tiles)
|
||||||
|
originEventTiles = cloneArray(mapValue.mapEventTiles)
|
||||||
|
|
||||||
|
const tileStorage = new TileStorage()
|
||||||
|
const allTiles = await tileStorage.getAll()
|
||||||
|
const allTileIds = allTiles.map((tile) => tile.id)
|
||||||
|
|
||||||
|
tileMap.value = createTileMap(scene, mapValue)
|
||||||
|
tileMapLayer.value = createTileLayer(tileMap.value, allTileIds)
|
||||||
|
|
||||||
|
addEventListener('keydown', handleKeyDown)
|
||||||
|
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_UP, handlePointerUp)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (tileMap.value) {
|
||||||
|
tileMap.value.destroyLayer('tiles')
|
||||||
|
tileMap.value.removeAllLayers()
|
||||||
|
tileMap.value.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
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_UP, handlePointerUp)
|
||||||
|
mapEditor.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
removeEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
|
</script>
|
@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<Image v-for="tile in mapEditor.currentMap.value?.mapEventTiles" v-bind="getImageProps(tile)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { MapEventTileType, type MapEventTile, type Map as MapT, type UUID } from '@/application/types'
|
||||||
|
import { uuidv4 } from '@/application/utilities'
|
||||||
|
import { type EditorCommand } from '@/components/gameMaster/mapEditor/Map.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { cloneArray, getTile, tileToWorldX, tileToWorldY } from '@/services/mapService'
|
||||||
|
import { Image } from 'phavuer'
|
||||||
|
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
|
||||||
|
defineExpose({ handlePointer, finalizeCommand, clearTiles })
|
||||||
|
|
||||||
|
const emit = defineEmits(['createCommand'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tileMap: Phaser.Tilemaps.Tilemap
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// *** COMMAND STATE ***
|
||||||
|
|
||||||
|
let currentCommand: EventTileCommand | null = null
|
||||||
|
|
||||||
|
class EventTileCommand implements EditorCommand {
|
||||||
|
public operation: 'draw' | 'erase' | 'clear' = 'draw'
|
||||||
|
public type: 'event_tile' = 'event_tile'
|
||||||
|
public affectedTiles: MapEventTile[] = []
|
||||||
|
|
||||||
|
apply(elements: MapEventTile[]) {
|
||||||
|
let tileVersion = cloneArray(elements) as MapEventTile[]
|
||||||
|
if (this.operation === 'draw') {
|
||||||
|
tileVersion = tileVersion.concat(this.affectedTiles)
|
||||||
|
} else if (this.operation === 'erase') {
|
||||||
|
tileVersion = tileVersion.filter((v) => !this.affectedTiles.includes(v))
|
||||||
|
} else if (this.operation === 'clear') {
|
||||||
|
tileVersion = []
|
||||||
|
}
|
||||||
|
return tileVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(operation: 'draw' | 'erase' | 'clear') {
|
||||||
|
this.operation = operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommandUpdate(tile?: MapEventTile, operation: 'draw' | 'erase' | 'clear') {
|
||||||
|
if (!currentCommand) {
|
||||||
|
currentCommand = new EventTileCommand(operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
//If position is already in, do not proceed
|
||||||
|
for (const priorTile of currentCommand.affectedTiles) {
|
||||||
|
if (priorTile.positionX === tile.positionX && priorTile.positionY == tile.positionY) return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCommand.affectedTiles.push(tile)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeCommand() {
|
||||||
|
if (!currentCommand) return
|
||||||
|
emit('createCommand', currentCommand)
|
||||||
|
currentCommand = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// *** HANDLERS ***
|
||||||
|
|
||||||
|
function getImageProps(tile: MapEventTile) {
|
||||||
|
return {
|
||||||
|
x: tileToWorldX(props.tileMap, tile.positionX, tile.positionY),
|
||||||
|
y: tileToWorldY(props.tileMap, tile.positionX, tile.positionY),
|
||||||
|
texture: tile.type,
|
||||||
|
depth: 999
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
|
||||||
|
// Check if there is a tile
|
||||||
|
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile) return
|
||||||
|
|
||||||
|
// Check if event tile already exists on position
|
||||||
|
const existingEventTile = map.mapEventTiles.find((eventTile) => eventTile.positionX === tile.x && eventTile.positionY === tile.y)
|
||||||
|
if (existingEventTile) return
|
||||||
|
|
||||||
|
// If teleport, check if there is a selected map
|
||||||
|
if (mapEditor.drawMode.value === 'teleport' && !mapEditor.teleportSettings.value.toMapId) return
|
||||||
|
|
||||||
|
const newEventTile = {
|
||||||
|
id: uuidv4() as UUID,
|
||||||
|
mapId: map.id,
|
||||||
|
map: map,
|
||||||
|
type: mapEditor.drawMode.value === 'blocking tile' ? MapEventTileType.BLOCK : MapEventTileType.TELEPORT,
|
||||||
|
positionX: tile.x,
|
||||||
|
positionY: tile.y,
|
||||||
|
teleport:
|
||||||
|
mapEditor.drawMode.value === 'teleport'
|
||||||
|
? {
|
||||||
|
toMap: mapEditor.teleportSettings.value.toMapId,
|
||||||
|
toPositionX: mapEditor.teleportSettings.value.toPositionX,
|
||||||
|
toPositionY: mapEditor.teleportSettings.value.toPositionY,
|
||||||
|
toRotation: mapEditor.teleportSettings.value.toRotation
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
createCommandUpdate(newEventTile, 'draw')
|
||||||
|
|
||||||
|
map.mapEventTiles.push(newEventTile)
|
||||||
|
}
|
||||||
|
|
||||||
|
function erase(pointer: Phaser.Input.Pointer, map: MapT) {
|
||||||
|
// Check if there is a tile
|
||||||
|
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile) return
|
||||||
|
|
||||||
|
// Check if event tile already exists on position
|
||||||
|
const existingEventTile = map.mapEventTiles.find((eventTile) => eventTile.positionX === tile.x && eventTile.positionY === tile.y)
|
||||||
|
if (!existingEventTile) return
|
||||||
|
|
||||||
|
if (mapEditor.drawMode.value !== existingEventTile.type.toLowerCase()) {
|
||||||
|
if (mapEditor.drawMode.value === 'blocking tile' && existingEventTile.type === MapEventTileType.BLOCK)
|
||||||
|
null //skip this case
|
||||||
|
else return
|
||||||
|
}
|
||||||
|
|
||||||
|
createCommandUpdate(existingEventTile, 'erase')
|
||||||
|
|
||||||
|
// Remove existing event tile
|
||||||
|
map.mapEventTiles = map.mapEventTiles.filter((eventTile) => eventTile.id !== existingEventTile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointer(pointer: Phaser.Input.Pointer) {
|
||||||
|
const map = mapEditor.currentMap.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
if (pointer.event.altKey) return
|
||||||
|
|
||||||
|
switch (mapEditor.tool.value) {
|
||||||
|
case 'pencil':
|
||||||
|
pencil(pointer, map)
|
||||||
|
break
|
||||||
|
case 'eraser':
|
||||||
|
erase(pointer, map)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTiles() {
|
||||||
|
if (mapEditor.currentMap.value.mapEventTiles.length === 0) return
|
||||||
|
createCommandUpdate(null, 'clear')
|
||||||
|
finalizeCommand()
|
||||||
|
}
|
||||||
|
</script>
|
157
src/components/gameMaster/mapEditor/mapPartials/MapTiles.vue
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<Controls v-if="tileMapLayer" :layer="tileMapLayer" :depth="0" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type EditorCommand } from '@/components/gameMaster/mapEditor/Map.vue'
|
||||||
|
import Controls from '@/components/utilities/Controls.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { cloneArray, createTileArray, getTile, placeTile, placeTiles } from '@/services/mapService'
|
||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
|
||||||
|
defineExpose({ handlePointer, finalizeCommand, clearTiles })
|
||||||
|
|
||||||
|
const emit = defineEmits(['createCommand'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tileMap: Phaser.Tilemaps.Tilemap
|
||||||
|
tileMapLayer: Phaser.Tilemaps.TilemapLayer
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// *** COMMAND STATE ***
|
||||||
|
|
||||||
|
let currentCommand: TileCommand | null = null
|
||||||
|
|
||||||
|
class TileCommand implements EditorCommand {
|
||||||
|
public operation: 'draw' | 'erase' | 'clear' = 'draw'
|
||||||
|
public type: 'tile' = 'tile'
|
||||||
|
public tileName: string = 'blank_tile'
|
||||||
|
public affectedTiles: number[][] = []
|
||||||
|
|
||||||
|
apply(elements: string[][]) {
|
||||||
|
let tileVersion
|
||||||
|
if (this.operation === 'clear') {
|
||||||
|
tileVersion = createTileArray(props.tileMapLayer.width, props.tileMapLayer.height, 'blank_tile')
|
||||||
|
} else {
|
||||||
|
tileVersion = cloneArray(elements) as string[][]
|
||||||
|
for (const position of this.affectedTiles) {
|
||||||
|
tileVersion[position[1]][position[0]] = this.tileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tileVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(operation: 'draw' | 'erase' | 'clear', tileName: string) {
|
||||||
|
this.operation = operation
|
||||||
|
this.tileName = tileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommandUpdate(x: number, y: number, tileName: string, operation: 'draw' | 'erase' | 'clear') {
|
||||||
|
if (!currentCommand) {
|
||||||
|
currentCommand = new TileCommand(operation, tileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
//If position is already in, do not proceed
|
||||||
|
for (const vec of currentCommand.affectedTiles) {
|
||||||
|
if (vec[0] === x && vec[1] === y) return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCommand.affectedTiles.push([x, y])
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeCommand() {
|
||||||
|
if (!currentCommand) return
|
||||||
|
emit('createCommand', currentCommand)
|
||||||
|
currentCommand = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// *** HANDLERS ***
|
||||||
|
|
||||||
|
function draw(pointer: Phaser.Input.Pointer, tileName: string) {
|
||||||
|
let map = mapEditor.currentMap.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
// Check if there is a tile
|
||||||
|
const tile = getTile(props.tileMapLayer, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile) return
|
||||||
|
|
||||||
|
// Place tile
|
||||||
|
placeTile(props.tileMap, props.tileMapLayer, tile.x, tile.y, tileName)
|
||||||
|
|
||||||
|
createCommandUpdate(tile.x, tile.y, tileName, tileName === 'blank_tile' ? 'erase' : 'draw')
|
||||||
|
|
||||||
|
// Adjust mapEditorStore.map.tiles
|
||||||
|
map.tiles[tile.y][tile.x] = tileName
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint(pointer: Phaser.Input.Pointer) {
|
||||||
|
let map = mapEditor.currentMap.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
// Set new tileArray with selected tile
|
||||||
|
const tileArray = createTileArray(props.tileMap.width, props.tileMap.height, mapEditor.selectedTile.value)
|
||||||
|
placeTiles(props.tileMap, props.tileMapLayer, tileArray)
|
||||||
|
|
||||||
|
// Adjust mapEditorStore.map.tiles
|
||||||
|
map.tiles = tileArray
|
||||||
|
}
|
||||||
|
|
||||||
|
// When alt is pressed, and the pointer is down, select the tile that the pointer is over
|
||||||
|
function tilePicker(pointer: Phaser.Input.Pointer) {
|
||||||
|
let map = mapEditor.currentMap.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
// Check if there is a tile
|
||||||
|
const tile = getTile(props.tileMapLayer, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile) return
|
||||||
|
|
||||||
|
// Select the tile
|
||||||
|
mapEditor.setSelectedTile(map.tiles[tile.y][tile.x])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointer(pointer: Phaser.Input.Pointer) {
|
||||||
|
// Check if left mouse button is pressed
|
||||||
|
if (!pointer.isDown && pointer.button === 0) return
|
||||||
|
|
||||||
|
// Check if shift is not pressed, this means we are moving the camera
|
||||||
|
if (pointer.event.shiftKey) return
|
||||||
|
|
||||||
|
// Check if alt is pressed
|
||||||
|
if (pointer.event.altKey) {
|
||||||
|
tilePicker(pointer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if draw mode is tile
|
||||||
|
switch (mapEditor.tool.value) {
|
||||||
|
case 'pencil':
|
||||||
|
draw(pointer, mapEditor.selectedTile.value!)
|
||||||
|
break
|
||||||
|
case 'eraser':
|
||||||
|
draw(pointer, 'blank_tile')
|
||||||
|
break
|
||||||
|
case 'paint':
|
||||||
|
paint(pointer)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *** LIFECYCLE ***
|
||||||
|
|
||||||
|
function clearTiles() {
|
||||||
|
const tileArray = createTileArray(props.tileMap.width, props.tileMap.height, 'blank_tile')
|
||||||
|
placeTiles(props.tileMap, props.tileMapLayer, tileArray)
|
||||||
|
createCommandUpdate(0, 0, 'blank_tile', 'clear')
|
||||||
|
finalizeCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!mapEditor.currentMap.value) return
|
||||||
|
const mapState = mapEditor.currentMap.value
|
||||||
|
|
||||||
|
placeTiles(props.tileMap, props.tileMapLayer, mapState.tiles)
|
||||||
|
})
|
||||||
|
</script>
|
@ -0,0 +1,201 @@
|
|||||||
|
<template>
|
||||||
|
<PlacedMapObject
|
||||||
|
v-if="mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'map_object' && mapEditor.isPlacedMapObjectPreviewEnabled.value && mapEditor.selectedMapObject.value && previewPlacedMapObject"
|
||||||
|
:tileMap
|
||||||
|
:tileMapLayer
|
||||||
|
:key="previewPlacedMapObject?.id"
|
||||||
|
:placedMapObject="previewPlacedMapObject as PlacedMapObjectT"
|
||||||
|
/>
|
||||||
|
<SelectedPlacedMapObjectComponent v-if="mapEditor.selectedPlacedObject.value" :key="mapEditor.selectedPlacedObject.value.id" :map :placedMapObject="mapEditor.selectedPlacedObject.value" @move="moveMapObject" @rotate="rotatePlacedMapObject" @delete="deletePlacedMapObject" />
|
||||||
|
<PlacedMapObject v-for="placedMapObject in mapEditor.currentMap.value?.placedMapObjects" :tileMap :tileMapLayer :placedMapObject @pointerdown="clickPlacedMapObject(placedMapObject)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MapObject, Map as MapT, PlacedMapObject as PlacedMapObjectT } from '@/application/types'
|
||||||
|
import { uuidv4 } from '@/application/utilities'
|
||||||
|
import PlacedMapObject from '@/components/game/map/partials/PlacedMapObject.vue'
|
||||||
|
import SelectedPlacedMapObjectComponent from '@/components/gameMaster/mapEditor/partials/SelectedPlacedMapObject.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { getTile } from '@/services/mapService'
|
||||||
|
import { useScene } from 'phavuer'
|
||||||
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||||
|
|
||||||
|
import Tilemap = Phaser.Tilemaps.Tilemap
|
||||||
|
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
|
||||||
|
|
||||||
|
const scene = useScene()
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
const map = computed(() => mapEditor.currentMap.value!)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'update', map: MapT): void; (e: 'updateAndCommit', map: MapT): void; (e: 'pauseObjectTracking'): void; (e: 'resumeObjectTracking'): void }>()
|
||||||
|
|
||||||
|
defineExpose({ handlePointer })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tileMap: Tilemap
|
||||||
|
tileMapLayer: TilemapLayer
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const previewPosition = ref({ x: 0, y: 0 })
|
||||||
|
const previewPlacedMapObject = computed(() => ({
|
||||||
|
id: mapEditor.selectedMapObject.value!.id,
|
||||||
|
mapObject: mapEditor.selectedMapObject.value!.id,
|
||||||
|
isRotated: false,
|
||||||
|
positionX: previewPosition.value.x,
|
||||||
|
positionY: previewPosition.value.y
|
||||||
|
}))
|
||||||
|
|
||||||
|
function updatePreviewPosition(pointer: Phaser.Input.Pointer) {
|
||||||
|
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile || (previewPosition.value.x === tile.x && previewPosition.value.y === tile.y)) return
|
||||||
|
|
||||||
|
previewPosition.value = { x: tile.x, y: tile.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
|
||||||
|
emit('pauseObjectTracking')
|
||||||
|
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile) return
|
||||||
|
|
||||||
|
// Check if object already exists on position
|
||||||
|
const existingPlacedMapObject = findObjectByPointer(pointer, mapEditor.currentMap.value!)
|
||||||
|
if (existingPlacedMapObject) return
|
||||||
|
|
||||||
|
if (!mapEditor.selectedMapObject.value) return
|
||||||
|
|
||||||
|
const newPlacedMapObject: PlacedMapObjectT = {
|
||||||
|
id: uuidv4(),
|
||||||
|
mapObject: mapEditor.selectedMapObject.value.id,
|
||||||
|
isRotated: false,
|
||||||
|
positionX: tile.x,
|
||||||
|
positionY: tile.y
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new object to mapObjects
|
||||||
|
mapEditor.selectedPlacedObject.value = newPlacedMapObject
|
||||||
|
map.placedMapObjects.push(newPlacedMapObject)
|
||||||
|
|
||||||
|
emit('update', map)
|
||||||
|
}
|
||||||
|
|
||||||
|
function eraser(pointer: Phaser.Input.Pointer, map: MapT) {
|
||||||
|
emit('pauseObjectTracking')
|
||||||
|
|
||||||
|
// Check if object already exists on position
|
||||||
|
const existingPlacedMapObject = findObjectByPointer(pointer, map)
|
||||||
|
if (!existingPlacedMapObject) return
|
||||||
|
|
||||||
|
// Remove existing object
|
||||||
|
map.placedMapObjects = map.placedMapObjects.filter((placedMapObject) => placedMapObject.id !== existingPlacedMapObject.id)
|
||||||
|
|
||||||
|
emit('update', map)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findObjectByPointer(pointer: Phaser.Input.Pointer, map: MapT): PlacedMapObjectT | undefined {
|
||||||
|
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile) return undefined
|
||||||
|
|
||||||
|
return map.placedMapObjects.find((placedMapObject) => placedMapObject.positionX === tile.x && placedMapObject.positionY === tile.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectPicker(pointer: Phaser.Input.Pointer, map: MapT) {
|
||||||
|
// Check if object already exists on position
|
||||||
|
const existingPlacedMapObject = findObjectByPointer(pointer, map)
|
||||||
|
if (!existingPlacedMapObject) return
|
||||||
|
|
||||||
|
// Select the object
|
||||||
|
mapEditor.setSelectedMapObject(existingPlacedMapObject.mapObject as MapObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveMapObject(id: string, map: MapT) {
|
||||||
|
mapEditor.movingPlacedObject.value = map.placedMapObjects.find((object) => object.id === id) as PlacedMapObjectT
|
||||||
|
|
||||||
|
emit('pauseObjectTracking')
|
||||||
|
|
||||||
|
function handlePointerMove(pointer: Phaser.Input.Pointer) {
|
||||||
|
if (!mapEditor.movingPlacedObject.value) return
|
||||||
|
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile) return
|
||||||
|
|
||||||
|
mapEditor.movingPlacedObject.value.positionX = tile.x
|
||||||
|
mapEditor.movingPlacedObject.value.positionY = tile.y
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.input.on(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
|
||||||
|
scene.input.on(Phaser.Input.Events.POINTER_UP, handlePointerUp)
|
||||||
|
|
||||||
|
function handlePointerUp(pointer: Phaser.Input.Pointer) {
|
||||||
|
scene.input.off(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
|
||||||
|
|
||||||
|
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
|
||||||
|
if (!tile) return
|
||||||
|
console.log(id)
|
||||||
|
map.placedMapObjects.map((placed) => {
|
||||||
|
if (placed.id === id) {
|
||||||
|
placed.positionX = tile.x
|
||||||
|
placed.positionY = tile.y
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mapEditor.movingPlacedObject.value = null
|
||||||
|
|
||||||
|
scene.input.off(Phaser.Input.Events.POINTER_UP, handlePointerUp)
|
||||||
|
emit('resumeObjectTracking')
|
||||||
|
emit('updateAndCommit', map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatePlacedMapObject(id: string, map: MapT) {
|
||||||
|
map.placedMapObjects.map((placed) => {
|
||||||
|
if (placed.id === id) {
|
||||||
|
placed.isRotated = !placed.isRotated
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('updateAndCommit', map)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePlacedMapObject(id: string, map: MapT) {
|
||||||
|
map.placedMapObjects = map.placedMapObjects.filter((object) => object.id !== id)
|
||||||
|
mapEditor.selectedPlacedObject.value = null
|
||||||
|
emit('updateAndCommit', map)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickPlacedMapObject(placedMapObject: PlacedMapObjectT) {
|
||||||
|
mapEditor.selectedPlacedObject.value = placedMapObject
|
||||||
|
|
||||||
|
// If alt is pressed, select the object
|
||||||
|
if (scene.input.activePointer.event.altKey) {
|
||||||
|
mapEditor.setSelectedMapObject(placedMapObject.mapObject as MapObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointer(pointer: Phaser.Input.Pointer) {
|
||||||
|
const map = mapEditor.currentMap.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
// Check if alt is pressed, this means we are selecting the object
|
||||||
|
if (pointer.event.altKey) return
|
||||||
|
|
||||||
|
// Check if tool is pencil
|
||||||
|
switch (mapEditor.tool.value) {
|
||||||
|
case 'pencil':
|
||||||
|
pencil(pointer, map)
|
||||||
|
break
|
||||||
|
case 'eraser':
|
||||||
|
eraser(pointer, map)
|
||||||
|
break
|
||||||
|
case 'object picker':
|
||||||
|
objectPicker(pointer, map)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scene.input.on(Phaser.Input.Events.POINTER_MOVE, updatePreviewPosition)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
scene.input.off(Phaser.Input.Events.POINTER_MOVE, updatePreviewPosition)
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,9 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<Modal :isModalOpen="true" @modal:close="() => zoneEditorStore.toggleCreateZoneModal()" :modal-width="300" :modal-height="420" :is-resizable="false" :disable-bg-texture="true">
|
<Modal ref="modalRef" :modal-width="300" :modal-height="420" :is-resizable="false" bg-style="none">
|
||||||
<template #modalHeader>
|
<template #modalHeader>
|
||||||
<h3 class="m-0 font-medium shrink-0 text-white">Create new zone</h3>
|
<h3 class="m-0 font-medium shrink-0 text-white">Create new map</h3>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #modalBody>
|
<template #modalBody>
|
||||||
<div class="m-4">
|
<div class="m-4">
|
||||||
<form method="post" @submit.prevent="submit" class="inline">
|
<form method="post" @submit.prevent="submit" class="inline">
|
||||||
@ -14,15 +13,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-field-half">
|
<div class="form-field-half">
|
||||||
<label for="name">Width</label>
|
<label for="name">Width</label>
|
||||||
<input class="input-field max-w-64" v-model="width" name="name" id="name" type="number" />
|
<input class="input-field max-w-64" v-model="width" name="width" id="width" type="number" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field-half">
|
<div class="form-field-half">
|
||||||
<label for="name">Height</label>
|
<label for="name">Height</label>
|
||||||
<input class="input-field max-w-64" v-model="height" name="name" id="name" type="number" />
|
<input class="input-field max-w-64" v-model="height" name="height" id="height" type="number" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field-full">
|
<div class="form-field-full">
|
||||||
<label for="name">PVP enabled</label>
|
<label for="name">PVP enabled</label>
|
||||||
<select class="input-field" name="pvp" id="pvp">
|
<select class="input-field" v-model="pvp" name="pvp" id="pvp">
|
||||||
<option :value="false">No</option>
|
<option :value="false">No</option>
|
||||||
<option :value="true">Yes</option>
|
<option :value="true">Yes</option>
|
||||||
</select>
|
</select>
|
||||||
@ -36,23 +35,47 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
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 { MapStorage } from '@/storage/storages'
|
||||||
import { useGameStore } from '@/stores/gameStore'
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
import { useZoneEditorStore } from '@/stores/zoneEditorStore'
|
import { ref, useTemplateRef } from 'vue'
|
||||||
import type { Zone } from '@/types'
|
|
||||||
|
const emit = defineEmits(['create'])
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const zoneEditorStore = useZoneEditorStore()
|
const mapStorage = new MapStorage()
|
||||||
|
const modalRef = useTemplateRef('modalRef')
|
||||||
|
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const width = ref(0)
|
const width = ref(0)
|
||||||
const height = ref(0)
|
const height = ref(0)
|
||||||
|
const pvp = ref(false)
|
||||||
|
|
||||||
function submit() {
|
defineExpose({ open: () => modalRef.value?.open() })
|
||||||
gameStore.connection.emit('gm:zone_editor:zone:create', { name: name.value, width: width.value, height: height.value }, (response: Zone[]) => {
|
|
||||||
zoneEditorStore.setZoneList(response)
|
async function submit() {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_MAP_CREATE, { name: name.value, width: width.value, height: height.value }, async (response: Map | false) => {
|
||||||
|
if (!response) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
name.value = ''
|
||||||
|
width.value = 0
|
||||||
|
height.value = 0
|
||||||
|
pvp.value = false
|
||||||
|
|
||||||
|
// Add map to storage
|
||||||
|
await mapStorage.add(response)
|
||||||
|
|
||||||
|
// Let list know to fetch new maps
|
||||||
|
emit('create')
|
||||||
})
|
})
|
||||||
zoneEditorStore.toggleCreateZoneModal()
|
|
||||||
|
// Close modal
|
||||||
|
modalRef.value?.close()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
84
src/components/gameMaster/mapEditor/partials/MapList.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<Modal ref="modalRef" :is-resizable="false" :modal-width="300" :modal-height="360" bg-style="none">
|
||||||
|
<template #modalHeader>
|
||||||
|
<h3 class="text-lg text-white">Maps</h3>
|
||||||
|
</template>
|
||||||
|
<template #modalBody>
|
||||||
|
<div class="my-4 mx-auto h-full">
|
||||||
|
<div class="text-center mb-4 px-2 flex gap-2.5">
|
||||||
|
<button class="btn-cyan py-1.5 min-w-[calc(50%_-_5px)]" @click="fetchMaps">Refresh</button>
|
||||||
|
<button class="btn-cyan py-1.5 min-w-[calc(50%_-_5px)]" @click="createMapModal?.open">New</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto h-[calc(100%-20px)]">
|
||||||
|
<div class="relative p-2.5 cursor-pointer flex gap-y-2.5 gap-x-5 flex-wrap" v-for="(map, index) in mapList" :key="map.id">
|
||||||
|
<div class="absolute left-0 top-0 w-full h-px bg-gray-500" v-if="index === 0"></div>
|
||||||
|
<div class="flex gap-3 items-center w-full" @click="() => loadMap(map.id)">
|
||||||
|
<span>{{ map.name }}</span>
|
||||||
|
<span class="ml-auto gap-1 flex">
|
||||||
|
<button class="btn-red w-7 h-7 z-50 flex items-center justify-center" @click.stop="() => deleteMap(map.id)">x</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<CreateMap ref="createMapModal" @create="fetchMaps" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { Map } from '@/application/types'
|
||||||
|
import CreateMap from '@/components/gameMaster/mapEditor/partials/CreateMap.vue'
|
||||||
|
import Modal from '@/components/utilities/Modal.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { MapStorage } from '@/storage/storages'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { onMounted, ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
const mapStorage = new MapStorage()
|
||||||
|
const mapList = ref<Map[]>([])
|
||||||
|
const modalRef = useTemplateRef('modalRef')
|
||||||
|
const createMapModal = useTemplateRef('createMapModal')
|
||||||
|
|
||||||
|
defineEmits(['open-create-map'])
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open: () => modalRef.value?.open()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchMaps()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchMaps() {
|
||||||
|
mapList.value = await mapStorage.getAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMap(id: string) {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_MAP_REQUEST, { mapId: id }, (response: Map) => {
|
||||||
|
mapEditor.loadMap(response)
|
||||||
|
})
|
||||||
|
modalRef.value?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMap(id: string) {
|
||||||
|
gameStore.connection?.emit(SocketEvent.GM_MAP_DELETE, { mapId: id }, async (response: boolean) => {
|
||||||
|
if (!response) {
|
||||||
|
gameStore.addNotification({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to delete map.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await mapStorage.delete(id)
|
||||||
|
await fetchMaps()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,96 @@
|
|||||||
|
<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="flex flex-col gap-2.5 p-2.5">
|
||||||
|
<div class="relative flex">
|
||||||
|
<img src="/assets/icons/mapEditor/search.svg" class="w-4 h-4 py-0.5 absolute top-1/2 -translate-y-1/2 left-2.5" alt="Search icon" />
|
||||||
|
<label class="mb-1.5 font-titles hidden" for="search">Search</label>
|
||||||
|
<input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" />
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<select class="input-field w-full" name="lists" v-model="mapEditor.drawMode.value" @change="(event: any) => mapEditor.setDrawMode(event.target.value)">
|
||||||
|
<option value="tile">Tiles</option>
|
||||||
|
<option value="map_object">Objects</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-full overflow-auto relative border-0 border-t border-solid border-gray-500 p-2.5">
|
||||||
|
<div class="h-full overflow-auto">
|
||||||
|
<div class="flex justify-between flex-wrap gap-2.5 items-center">
|
||||||
|
<div v-for="(mapObject, index) in filteredMapObjects" :key="index" class="max-w-1/4 inline-block">
|
||||||
|
<img
|
||||||
|
class="border-2 border-solid rounded max-w-full"
|
||||||
|
:src="`${config.server_endpoint}/textures/map_objects/${mapObject.id}.png`"
|
||||||
|
alt="Object"
|
||||||
|
@click="mapEditor.setSelectedMapObject(mapObject)"
|
||||||
|
:class="{
|
||||||
|
'cursor-pointer transition-all duration-300': true,
|
||||||
|
'border-cyan shadow-lg': mapEditor.selectedMapObject.value?.id === mapObject.id,
|
||||||
|
'border-transparent hover:border-gray-300': mapEditor.selectedMapObject.value?.id !== mapObject.id
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col h-40 gap-2.5 p-3.5 border-t border-0 border-solid border-gray-500">
|
||||||
|
<span>Tags:</span>
|
||||||
|
<div class="flex grow items-center flex-wrap gap-1.5 overflow-auto">
|
||||||
|
<span class="m-auto">No tags selected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import config from '@/application/config'
|
||||||
|
import type { MapObject } from '@/application/types'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { MapObjectStorage } from '@/storage/storages'
|
||||||
|
import { liveQuery } from 'dexie'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const mapObjectStorage = new MapObjectStorage()
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedTags = ref<string[]>([])
|
||||||
|
const mapObjectList = ref<MapObject[]>([])
|
||||||
|
|
||||||
|
const uniqueTags = computed(() => {
|
||||||
|
const allTags = mapObjectList.value.flatMap((obj) => obj.tags || [])
|
||||||
|
return Array.from(new Set(allTags))
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
if (selectedTags.value.includes(tag)) {
|
||||||
|
selectedTags.value = selectedTags.value.filter((t) => t !== tag)
|
||||||
|
} else {
|
||||||
|
selectedTags.value.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMapObjects = computed(() => {
|
||||||
|
return mapObjectList.value.filter((object) => {
|
||||||
|
const matchesSearch = !searchQuery.value || object.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
|
const matchesTags = selectedTags.value.length === 0 || (object.tags && selectedTags.value.some((tag) => object.tags.includes(tag)))
|
||||||
|
return matchesSearch && matchesTags
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let subscription: any = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
subscription = liveQuery(() => mapObjectStorage.liveQuery()).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
mapObjectList.value = result
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Failed to fetch tiles:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (!subscription) return
|
||||||
|
subscription.unsubscribe()
|
||||||
|
})
|
||||||
|
</script>
|
98
src/components/gameMaster/mapEditor/partials/MapSettings.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<Modal ref="modalRef" :modal-width="600" :modal-height="430" bg-style="none">
|
||||||
|
<template #modalHeader>
|
||||||
|
<h3 class="m-0 font-medium shrink-0 text-white">Map settings</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #modalBody>
|
||||||
|
<div class="m-4">
|
||||||
|
<div class="space-x-2">
|
||||||
|
<button class="btn-cyan py-1.5 px-4" type="button" @click.prevent="screen = 'settings'">Settings</button>
|
||||||
|
<button class="btn-cyan py-1.5 px-4" type="button" @click.prevent="screen = 'effects'">Effects</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" @submit.prevent="" class="inline" v-if="screen === 'settings'">
|
||||||
|
<div class="gap-2.5 flex flex-wrap mt-4">
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input class="input-field" v-model="name" @input="updateValue" name="name" id="name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-half">
|
||||||
|
<label for="width">Width</label>
|
||||||
|
<input class="input-field" v-model="width" @input="updateValue" name="width" id="width" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-half">
|
||||||
|
<label for="height">Height</label>
|
||||||
|
<input class="input-field" v-model="height" @input="updateValue" name="height" id="height" type="number" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mr-4" for="pvp">PVP enabled</label>
|
||||||
|
<input type="checkbox" v-model="pvp" @input="updateValue" class="input-field scale-125" name="pvp" id="pvp" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" @submit.prevent="" class="inline" v-if="screen === 'effects'">
|
||||||
|
<div v-for="(effect, index) in mapEffects" :key="effect.id" class="mb-2 flex items-center space-x-2 mt-4">
|
||||||
|
<input class="input-field flex-grow" v-model="effect.effect" placeholder="Effect name" />
|
||||||
|
<input class="input-field w-20" v-model.number="effect.strength" type="number" placeholder="Strength" />
|
||||||
|
<button class="btn-red py-1 px-2" type="button" @click="removeEffect(index)">Delete</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-green py-1 px-2 mt-2" type="button" @click="addEffect">Add Effect</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UUID } from '@/application/types'
|
||||||
|
import { uuidv4 } from '@/application/utilities'
|
||||||
|
import Modal from '@/components/utilities/Modal.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||||
|
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
const screen = ref('settings')
|
||||||
|
|
||||||
|
const name = ref<string | undefined>('Map')
|
||||||
|
const width = ref<number>(0)
|
||||||
|
const height = ref<number>(0)
|
||||||
|
const pvp = ref<boolean>(false)
|
||||||
|
const mapEffects = ref(mapEditor.currentMap.value?.mapEffects || [])
|
||||||
|
const modalRef = useTemplateRef('modalRef')
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open: () => modalRef.value?.open()
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateValue(event: Event) {
|
||||||
|
let ev = event.target as HTMLInputElement
|
||||||
|
const value = ev.name === 'pvp' ? (ev.checked ? 1 : 0) : ev.value
|
||||||
|
mapEditor.updateProperty(ev.name as 'name' | 'width' | 'height' | 'pvp' | 'mapEffects', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mapEditor.currentMap.value,
|
||||||
|
(map) => {
|
||||||
|
if (!map) return
|
||||||
|
name.value = map.name
|
||||||
|
width.value = map.width
|
||||||
|
height.value = map.height
|
||||||
|
pvp.value = map.pvp
|
||||||
|
mapEffects.value = map.mapEffects
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const addEffect = () => {
|
||||||
|
mapEffects.value.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
effect: '',
|
||||||
|
strength: 1
|
||||||
|
})
|
||||||
|
mapEditor.updateProperty('mapEffects', mapEffects.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEffect = (index: number) => {
|
||||||
|
mapEffects.value.splice(index, 1)
|
||||||
|
mapEditor.updateProperty('mapEffects', mapEffects.value)
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center px-5 py-1 fixed bottom-20 left-0 z-20">
|
||||||
|
<div class="flex h-10 gap-2">
|
||||||
|
<button @mousedown.stop @click="handleDelete" class="btn-red !py-3 px-4">
|
||||||
|
<img src="/assets/icons/trashcan.svg" class="w-4 h-4" alt="Delete" />
|
||||||
|
</button>
|
||||||
|
<button @mousedown.stop @click="showMapObjectSettings = !showMapObjectSettings" class="btn-indigo !py-3 px-4">
|
||||||
|
<img src="/assets/icons/mapEditor/gear.svg" class="w-4 h-4 invert" alt="Delete" />
|
||||||
|
</button>
|
||||||
|
<button @mousedown.stop @click="handleRotate" class="btn-cyan py-1.5 px-4">Rotate</button>
|
||||||
|
<button @mousedown.stop @click="handleMove" class="btn-cyan py-1.5 px-4 min-w-24">Move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal :is-modal-open="showMapObjectSettings" @modal:close="showMapObjectSettings = false" :modal-height="320" bg-style="none">
|
||||||
|
<template #modalHeader>
|
||||||
|
<h3 class="m-0 font-medium shrink-0 text-white">Map object settings</h3>
|
||||||
|
</template>
|
||||||
|
<template #modalBody>
|
||||||
|
<div class="m-4">
|
||||||
|
<form method="post" @submit.prevent="" class="inline">
|
||||||
|
<div class="gap-2.5 flex flex-wrap">
|
||||||
|
<div class="form-field-full">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input class="input-field" v-model="mapObjectName" name="name" id="name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-half">
|
||||||
|
<label for="originX">Origin X</label>
|
||||||
|
<input class="input-field" v-model="mapObjectOriginX" name="originX" id="originX" type="number" min="0.0" step="0.01" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-half">
|
||||||
|
<label for="originY">Origin Y</label>
|
||||||
|
<input class="input-field" v-model="mapObjectOriginY" name="originY" id="originY" type="number" min="0.0" step="0.01" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-cyan px-4 py-1.5 min-w-24" @click="handleUpdate">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SocketEvent } from '@/application/enums'
|
||||||
|
import type { MapObject, Map as MapT, PlacedMapObject } from '@/application/types'
|
||||||
|
import Modal from '@/components/utilities/Modal.vue'
|
||||||
|
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
|
||||||
|
import { MapObjectStorage } from '@/storage/storages'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
placedMapObject: PlacedMapObject
|
||||||
|
map: MapT
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['move', 'rotate', 'delete'])
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const mapEditor = useMapEditorComposable()
|
||||||
|
|
||||||
|
const mapObjectStorage = new MapObjectStorage()
|
||||||
|
const mapObject = ref<MapObject | null>(null)
|
||||||
|
const showMapObjectSettings = ref(false)
|
||||||
|
const mapObjectName = ref('')
|
||||||
|
const mapObjectOriginX = ref(0)
|
||||||
|
const mapObjectOriginY = ref(0)
|
||||||
|
|
||||||
|
const handleMove = () => {
|
||||||
|
emit('move', props.placedMapObject.id, props.map)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRotate = () => {
|
||||||
|
emit('rotate', props.placedMapObject.id, props.map)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
emit('delete', props.placedMapObject.id, props.map)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate() {
|
||||||
|
if (!mapObject.value) return
|
||||||
|
|
||||||
|
gameStore.connection?.emit(
|
||||||
|
SocketEvent.GM_MAPOBJECT_UPDATE,
|
||||||
|
{
|
||||||
|
id: props.placedMapObject.mapObject as string,
|
||||||
|
name: mapObjectName.value,
|
||||||
|
originX: mapObjectOriginX.value,
|
||||||
|
originY: mapObjectOriginY.value
|
||||||
|
},
|
||||||
|
async (response: boolean) => {
|
||||||
|
if (!response) return
|
||||||
|
await mapObjectStorage.update(mapObject.value!.id, {
|
||||||
|
name: mapObjectName.value,
|
||||||
|
originX: mapObjectOriginX.value,
|
||||||
|
originY: mapObjectOriginY.value
|
||||||
|
})
|
||||||
|
mapEditor.triggerMapObjectRefresh()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!props.placedMapObject.mapObject) return
|
||||||
|
|
||||||
|
mapObject.value = await mapObjectStorage.getById(props.placedMapObject.mapObject as string)
|
||||||
|
if (!mapObject.value) return
|
||||||
|
|
||||||
|
mapObjectName.value = mapObject.value.name
|
||||||
|
mapObjectOriginX.value = mapObject.value.originX
|
||||||
|
mapObjectOriginY.value = mapObject.value.originY
|
||||||
|
})
|
||||||
|
</script>
|