1
0
forked from noxious/client

Compare commits

..

1 Commits

Author SHA1 Message Date
175d7c6199 Added overlay on portrait screens to force switching to landscape
(Needs better styling/design)
2025-01-22 22:36:08 +01:00
142 changed files with 5116 additions and 10701 deletions

View File

@ -1,6 +1,5 @@
VITE_NAME=Noxious VITE_NAME=Noxious
VITE_DOMAIN=localhost VITE_DEVELOPMENT=true
VITE_ENVIRONMENT=development
VITE_SERVER_ENDPOINT=http://localhost:4000 VITE_SERVER_ENDPOINT=http://localhost:4000
VITE_TILE_SIZE_WIDTH=64 VITE_TILE_SIZE_WIDTH=64
VITE_TILE_SIZE_HEIGHT=32 VITE_TILE_SIZE_HEIGHT=32

13
.eslintrc.cjs Normal file
View File

@ -0,0 +1,13 @@
/* 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'
}
}

View File

@ -1,6 +1,7 @@
{ {
"recommendations": [ "recommendations": [
"Vue.volar", "Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode" "esbenp.prettier-vscode"
] ]
} }

View File

@ -1,70 +0,0 @@
{
# 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 Normal file
View File

@ -0,0 +1,32 @@
# 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;"]

4
captain-definition Normal file
View File

@ -0,0 +1,4 @@
{
"schemaVersion": 2,
"dockerfilePath" :"./Dockerfile"
}

16
nginx.conf Normal file
View File

@ -0,0 +1,16 @@
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;
}
}

3384
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,36 +11,39 @@
"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": {
"@vueuse/core": "^10.5.0", "@vueuse/core": "^10.5.0",
"@vueuse/integrations": "^10.5.0", "@vueuse/integrations": "^10.5.0",
"axios": "^1.7.9", "axios": "^1.7.7",
"dexie": "^4.0.11", "dexie": "^4.0.8",
"phaser": "^3.88.2", "phaser": "^3.86.0",
"phaser3-rex-plugins": "^1.80.13", "pinia": "^2.1.6",
"phavuer": "^0.16.5", "socket.io-client": "^4.8.0",
"pinia": "^2.3.1",
"sharp": "^0.33.5",
"socket.io-client": "^4.8.1",
"universal-cookie": "^6.1.3", "universal-cookie": "^6.1.3",
"vite-plugin-image-optimizer": "^1.1.8", "vue": "^3.5.12",
"vue": "^3.5.13", "zod": "^3.22.2"
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.0", "@ianvs/prettier-plugin-sort-imports": "^4.4.0",
"@tauri-apps/cli": "^2.2.7", "@rushstack/eslint-patch": "^1.10.3",
"@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",
"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",
"phavuer": "^0.16.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"sass": "^1.79.4", "sass": "^1.79.4",

View File

@ -1,4 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 325 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,59 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 453 KiB

View File

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

5149
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
<template> <template>
<Debug /> <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>
@ -12,44 +13,43 @@ import Game from '@/components/screens/Game.vue'
import Loading from '@/components/screens/Loading.vue' import Loading from '@/components/screens/Loading.vue'
import Login from '@/components/screens/Login.vue' import Login from '@/components/screens/Login.vue'
import MapEditor from '@/components/screens/MapEditor.vue' import MapEditor from '@/components/screens/MapEditor.vue'
import BackgroundImageLoader from '@/components/utilities/BackgroundImageLoader.vue'
import Debug from '@/components/utilities/Debug.vue' import Debug from '@/components/utilities/Debug.vue'
import Notifications from '@/components/utilities/Notifications.vue' import Notifications from '@/components/utilities/Notifications.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { useSoundComposable } from '@/composables/useSoundComposable'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useMapEditorStore } from '@/stores/mapEditorStore'
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
const gameStore = useGameStore() const gameStore = useGameStore()
const mapEditorStore = useMapEditorStore()
const mapEditor = useMapEditorComposable()
const { playSound } = useSoundComposable()
const currentScreen = computed(() => { const currentScreen = computed(() => {
if (!gameStore.game.isLoaded) return Loading if (!gameStore.game.isLoaded) return Loading
if (!socketManager.connection) return Login if (!gameStore.connection) return Login
if (!socketManager.token) return Login if (!gameStore.token) return Login
if (!gameStore.character) return Characters if (!gameStore.character) return Characters
if (mapEditor.active.value) return MapEditor if (mapEditorStore.active) return MapEditor
return Game return Game
}) })
// Watch mapEditor.active and empty gameStore.game.loadedAssets // Watch mapEditorStore.active and empty gameStore.game.loadedAssets
watch( watch(
() => mapEditor.active.value, () => mapEditorStore.active,
() => { () => {
gameStore.game.loadedTextures = [] gameStore.game.loadedTextures = []
} }
) )
// #209: Play sound when a button is pressed // #209: Play sound when a button is pressed
/**
* @TODO: Not all button-like elements will actually be a button, so we need to find a better way to do this
*/
addEventListener('click', (event) => { addEventListener('click', (event) => {
const classList = ['btn-cyan', 'btn-red', 'btn-indigo', 'btn-empty', 'btn-sound'] if (!(event.target instanceof HTMLButtonElement)) {
const target = event.target as HTMLElement return
// 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

View File

@ -1,7 +1,6 @@
export default { export default {
name: import.meta.env.VITE_NAME, name: import.meta.env.VITE_NAME,
domain: import.meta.env.VITE_DOMAIN, development: import.meta.env.VITE_DEVELOPMENT === 'true',
environment: import.meta.env.VITE_ENVIRONMENT,
server_endpoint: import.meta.env.VITE_SERVER_ENDPOINT, server_endpoint: import.meta.env.VITE_SERVER_ENDPOINT,
tile_size: { tile_size: {
width: Number(import.meta.env.VITE_TILE_SIZE_WIDTH), width: Number(import.meta.env.VITE_TILE_SIZE_WIDTH),

View File

@ -1,63 +0,0 @@
export enum Direction {
POSITIVE,
NEGATIVE,
UNCHANGED
}
export enum SocketEvent {
CONNECT_ERROR = 'connect_error',
RECONNECT_FAILED = 'reconnect_failed',
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 = 'disconnect',
USER_DISCONNECT = '15',
LOGIN = '14',
LOGGED_IN = '13',
NOTIFICATION = '12',
DATE = '11',
FAILED = '10',
COMPLETED = '9',
CONNECTION = 'connection',
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'
}

View File

@ -19,6 +19,7 @@ export type TextureData = {
updatedAt: Date updatedAt: Date
originX?: number originX?: number
originY?: number originY?: number
isAnimated?: boolean
frameRate?: number frameRate?: number
frameWidth?: number frameWidth?: number
frameHeight?: number frameHeight?: number
@ -26,7 +27,7 @@ export type TextureData = {
} }
export type Tile = { export type Tile = {
id: string id: UUID
name: string name: string
tags: any | null tags: any | null
createdAt: Date createdAt: Date
@ -34,12 +35,12 @@ export type Tile = {
} }
export type MapObject = { export type MapObject = {
id: string id: UUID
name: string name: string
tags: string[] tags: any | null
depthOffsets: number[]
originX: number originX: number
originY: number originY: number
isAnimated: boolean
frameRate: number frameRate: number
frameWidth: number frameWidth: number
frameHeight: number frameHeight: number
@ -48,7 +49,7 @@ export type MapObject = {
} }
export type Item = { export type Item = {
id: string id: UUID
name: string name: string
description: string | null description: string | null
itemType: ItemType itemType: ItemType
@ -63,11 +64,11 @@ export type ItemType = 'WEAPON' | 'HELMET' | 'CHEST' | 'LEGS' | 'BOOTS' | 'GLOVE
export type ItemRarity = 'COMMON' | 'UNCOMMON' | 'RARE' | 'EPIC' | 'LEGENDARY' export type ItemRarity = 'COMMON' | 'UNCOMMON' | 'RARE' | 'EPIC' | 'LEGENDARY'
export type Map = { export type Map = {
id: string id: UUID
name: string name: string
width: number width: number
height: number height: number
tiles: string[][] tiles: any | null
pvp: boolean pvp: boolean
mapEffects: MapEffect[] mapEffects: MapEffect[]
mapEventTiles: MapEventTile[] mapEventTiles: MapEventTile[]
@ -79,14 +80,17 @@ export type Map = {
} }
export type MapEffect = { export type MapEffect = {
id: string id: UUID
map: Map
effect: string effect: string
strength: number strength: number
} }
export type PlacedMapObject = { export type PlacedMapObject = {
id: string id: UUID
mapObject: MapObject | string map: Map
mapObject: MapObject
depth: number
isRotated: boolean isRotated: boolean
positionX: number positionX: number
positionY: number positionY: number
@ -100,8 +104,8 @@ export enum MapEventTileType {
} }
export type MapEventTile = { export type MapEventTile = {
id: string id: UUID
map: string map: Map
type: MapEventTileType type: MapEventTileType
positionX: number positionX: number
positionY: number positionY: number
@ -109,7 +113,7 @@ export type MapEventTile = {
} }
export type MapEventTileTeleport = { export type MapEventTileTeleport = {
id: string id: UUID
mapEventTile: MapEventTile mapEventTile: MapEventTile
toMap: Map toMap: Map
toPositionX: number toPositionX: number
@ -118,7 +122,7 @@ export type MapEventTileTeleport = {
} }
export type User = { export type User = {
id: string id: UUID
username: string username: string
password: string password: string
characters: Character[] characters: Character[]
@ -138,7 +142,7 @@ export enum CharacterRace {
} }
export type CharacterType = { export type CharacterType = {
id: string id: UUID
name: string name: string
gender: CharacterGender gender: CharacterGender
race: CharacterRace race: CharacterRace
@ -149,17 +153,16 @@ export type CharacterType = {
} }
export type CharacterHair = { export type CharacterHair = {
id: string id: UUID
name: string name: string
sprite: string | Sprite sprite?: Sprite
gender: CharacterGender gender: CharacterGender
color: string
isSelectable: boolean isSelectable: boolean
} }
export type Character = { export type Character = {
id: string id: UUID
userid: string userId: UUID
user: User user: User
name: string name: string
hitpoints: number hitpoints: number
@ -182,18 +185,17 @@ export type Character = {
export type MapCharacter = { export type MapCharacter = {
character: Character character: Character
isMoving: boolean isMoving: boolean
isAttacking?: boolean
} }
export type CharacterItem = { export type CharacterItem = {
id: string id: UUID
character: Character character: Character
item: Item item: Item
quantity: number quantity: number
} }
export type CharacterEquipment = { export type CharacterEquipment = {
id: string id: UUID
slot: CharacterEquipmentSlotType slot: CharacterEquipmentSlotType
characterItem: CharacterItem characterItem: CharacterItem
} }
@ -208,38 +210,30 @@ export enum CharacterEquipmentSlotType {
} }
export type Sprite = { export type Sprite = {
id: string id: UUID
name: string name: string
width: number | null
height: number | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
spriteActions: SpriteAction[] spriteActions: SpriteAction[]
characterTypes: CharacterType[] characterTypes: CharacterType[]
} }
export interface SpriteImage {
url: string
offset: {
x: number
y: number
}
}
export type SpriteAction = { export type SpriteAction = {
id: string id: UUID
sprite: string sprite: Sprite
action: string action: string
sprites: SpriteImage[] sprites: string[]
originX: number originX: number
originY: number originY: number
isAnimated: boolean
isLooping: boolean
frameWidth: number frameWidth: number
frameHeight: number frameHeight: number
frameRate: number frameRate: number
} }
export type Chat = { export type Chat = {
id: string id: UUID
character: Character character: Character
map: Map map: Map
message: string message: string
@ -248,15 +242,19 @@ export type Chat = {
export type WorldSettings = { export type WorldSettings = {
date: Date date: Date
weatherState: WeatherState isRainEnabled: boolean
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 = { export type mapLoadData = {
mapId: string mapId: UUID
characters: MapCharacter[] characters: MapCharacter[]
} }

View File

@ -1,45 +1,25 @@
import config from '@/application/config'
import type { HttpResponse } from '@/application/types'
import type { BaseStorage } from '@/storage/baseStorage'
export function uuidv4() { 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)) 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[]) { export function unduplicateArray(array: any[]) {
const arrayToProcess = typeof array.flat === 'function' ? array.flat() : array return [...new Set(array.flat())]
return [...new Set(arrayToProcess)]
} }
export async function downloadCache<T extends { id: string; updatedAt: Date }>(endpoint: string, storage: BaseStorage<T>) { export function getDomain() {
const request = await fetch(`${config.server_endpoint}/cache/${endpoint}`) // Check if not localhost
const response = (await request.json()) as HttpResponse<T[]> if (window.location.hostname !== 'localhost') {
return window.location.hostname
if (!response.success) {
console.error(`Failed to download ${endpoint}:`, response.message)
return
} }
const items = response.data ?? [] // Check if not IP address
const serverItemIds = new Set(items.map((item) => item.id)) if (window.location.hostname.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
return window.location.hostname
// Remove items that don't exist on server
const existingItems = await storage.getAll()
for (const existingItem of existingItems) {
if (!serverItemIds.has(existingItem.id)) {
await storage.delete(existingItem.id)
}
} }
// Update or add new items if (window.location.hostname.split('.').length < 3) {
for (const item of items) { return window.location.hostname
let overwrite = false
const existingItem = await storage.getById(item.id)
if (!existingItem || item.updatedAt > existingItem.updatedAt) {
overwrite = true
} }
await storage.add(item, overwrite) return window.location.hostname.split('.').slice(-2).join('.')
}
} }

View File

@ -31,6 +31,12 @@ body {
@apply outline-offset-2; @apply outline-offset-2;
@apply rounded-sm; @apply rounded-sm;
} }
@media only screen and (orientation:portrait) and (max-width: 768px) {
.portrait-mode-notice {
@apply block;
}
}
} }
h1, h1,
@ -73,7 +79,7 @@ input {
} }
.input-field { .input-field {
@apply px-4 py-2.5 text-base leading-5 bg-gray border border-solid border-gray-500 rounded text-gray-300 font-default; @apply px-4 py-2.5 text-base leading-5 bg-gray border border-solid border-gray-500 rounded text-gray-300;
&:focus-visible { &:focus-visible {
@apply outline-none border-cyan rounded bg-gray-900; @apply outline-none border-cyan rounded bg-gray-900;
} }
@ -88,12 +94,6 @@ 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 {
@ -124,16 +124,7 @@ button {
&.active, &.active,
&:hover { &:hover {
@apply bg-red-500; @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;
} }
} }
@ -164,10 +155,6 @@ button {
@apply bg-gray bg-none; @apply bg-gray bg-none;
} }
.list-open {
@apply w-[calc(75%_-_40px)] max-xl:w-[calc(100%_-_360px)];
}
.hair-deselect:has(:checked) { .hair-deselect:has(:checked) {
img { img {
@apply brightness-200; @apply brightness-200;

View File

@ -1,103 +1,177 @@
<template> <template>
<Container ref="characterContainer" :x="currentPositionX" :y="currentPositionY" :depth="isometricDepth"> <ChatBubble :mapCharacter="props.mapCharacter" :currentX="currentPositionX" :currentY="currentPositionY" />
<ChatBubble :mapCharacter="props.mapCharacter" /> <Healthbar :mapCharacter="props.mapCharacter" :currentX="currentPositionX" :currentY="currentPositionY" />
<HealthBar :mapCharacter="props.mapCharacter" /> <Container ref="charContainer" :depth="isometricDepth" :x="currentPositionX" :y="currentPositionY">
<CharacterHair :mapCharacter="props.mapCharacter" :flipX="isFlippedX" /> <Sprite ref="charSprite" :origin-y="1" :flipX="isFlippedX" />
<Sprite ref="characterSprite" :flipX="isFlippedX" />
</Container> </Container>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import config from '@/application/config'
import { type MapCharacter } from '@/application/types' 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 ChatBubble from '@/components/game/character/partials/ChatBubble.vue'
import HealthBar from '@/components/game/character/partials/HealthBar.vue' import Healthbar from '@/components/game/character/partials/Healthbar.vue'
import { useCharacterSpriteComposable } from '@/composables/useCharacterSpriteComposable' import { loadSpriteTextures } from '@/composables/gameComposable'
import { useSoundComposable } from '@/composables/useSoundComposable' import { calculateIsometricDepth, tileToWorldX, tileToWorldY } from '@/composables/mapComposable'
import { CharacterTypeStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore' import { useMapStore } from '@/stores/mapStore'
import { Container, Sprite, useScene } from 'phavuer' import { Container, refObj, Sprite, useScene } from 'phavuer'
import { onMounted, onUnmounted, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
enum Direction {
POSITIVE,
NEGATIVE,
UNCHANGED
}
const props = defineProps<{ const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap tilemap: Phaser.Tilemaps.Tilemap
mapCharacter: MapCharacter mapCharacter: MapCharacter
}>() }>()
const charContainer = refObj<Phaser.GameObjects.Container>()
const charSprite = refObj<Phaser.GameObjects.Sprite>()
const charSpriteId = ref('')
const gameStore = useGameStore() const gameStore = useGameStore()
const mapStore = useMapStore() const mapStore = useMapStore()
const scene = useScene() const scene = useScene()
const { characterContainer, characterSprite, currentPositionX, currentPositionY, isometricDepth, isFlippedX, updatePosition, playAnimation, updateSprite, initializeSprite, cleanup } = useCharacterSpriteComposable(scene, props.tileMap, props.mapCharacter) const currentPositionX = ref(0)
const { playSound, stopSound } = useSoundComposable() const currentPositionY = ref(0)
const isometricDepth = ref(1)
const isInitialPosition = ref(true)
const tween = ref<Phaser.Tweens.Tween | null>(null)
const handlePositionUpdate = (newValues: any, oldValues: any) => { const updateIsometricDepth = (positionX: number, positionY: number) => {
if (!newValues) return isometricDepth.value = calculateIsometricDepth(positionX, positionY, 28, 94, true)
}
if (!oldValues || newValues.positionX !== oldValues.positionX || newValues.positionY !== oldValues.positionY) { const updatePosition = (positionX: number, positionY: number, direction: Direction) => {
updatePosition(newValues.positionX, newValues.positionY) const newPositionX = tileToWorldX(props.tilemap, positionX, positionY)
const newPositionY = tileToWorldY(props.tilemap, positionX, positionY)
if (isInitialPosition.value) {
currentPositionX.value = newPositionX
currentPositionY.value = newPositionY
isInitialPosition.value = false
return
} }
if (newValues.isMoving !== oldValues?.isMoving || newValues.rotation !== oldValues?.rotation) { if (tween.value?.isPlaying()) {
updateSprite() tween.value.stop()
}
const distance = Math.sqrt(Math.pow(newPositionX - currentPositionX.value, 2) + Math.pow(newPositionY - currentPositionY.value, 2))
if (distance >= config.tile_size.width / 1.1) {
currentPositionX.value = newPositionX
currentPositionY.value = newPositionY
return
}
const duration = distance * 5.7
tween.value = props.tilemap.scene.tweens.add({
targets: { x: currentPositionX.value, y: currentPositionY.value },
x: newPositionX,
y: newPositionY,
duration,
ease: 'Linear',
onStart: () => {
if (direction === Direction.POSITIVE) {
updateIsometricDepth(positionX, positionY)
}
},
onUpdate: (tween) => {
// @ts-ignore
currentPositionX.value = tween.targets[0].x
// @ts-ignore
currentPositionY.value = tween.targets[0].y
},
onComplete: () => {
if (direction === Direction.NEGATIVE) {
updateIsometricDepth(positionX, positionY)
}
}
})
}
const calcDirection = (oldPositionX: number, oldPositionY: number, newPositionX: number, newPositionY: number): Direction => {
if (newPositionY < oldPositionY || newPositionX < oldPositionX) return Direction.NEGATIVE
if (newPositionX > oldPositionX || newPositionY > oldPositionY) return Direction.POSITIVE
return Direction.UNCHANGED
}
const isFlippedX = computed(() => [6, 4].includes(props.mapCharacter.character.rotation ?? 0))
const charTexture = computed(() => {
const spriteId = charSpriteId.value ?? 'idle_right_down'
const action = props.mapCharacter.isMoving ? 'walk' : 'idle'
const direction = [0, 6].includes(props.mapCharacter.character.rotation) ? 'left_up' : 'right_down'
return `${spriteId}-${action}_${direction}`
})
const updateSprite = () => {
if (props.mapCharacter.isMoving) {
charSprite.value!.anims.play(charTexture.value, true)
} else {
charSprite.value!.anims.stop()
charSprite.value!.setFrame(0)
charSprite.value!.setTexture(charTexture.value)
} }
} }
/**
* 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( watch(
() => ({ () => ({
positionX: props.mapCharacter.character.positionX, positionX: props.mapCharacter.character.positionX,
positionY: props.mapCharacter.character.positionY, positionY: props.mapCharacter.character.positionY,
isMoving: props.mapCharacter.isMoving, isMoving: props.mapCharacter.isMoving,
rotation: props.mapCharacter.character.rotation, rotation: props.mapCharacter.character.rotation
isAttacking: props.mapCharacter.isAttacking
}), }),
async (oldValues, newValues) => { (newValues, oldValues) => {
handlePositionUpdate(oldValues, newValues) if (!newValues) return
if (!oldValues || newValues.positionX !== oldValues.positionX || newValues.positionY !== oldValues.positionY) {
const direction = !oldValues ? Direction.POSITIVE : calcDirection(oldValues.positionX, oldValues.positionY, newValues.positionX, newValues.positionY)
updatePosition(newValues.positionX, newValues.positionY, direction)
}
// Handle animation updates
if (newValues.isMoving !== oldValues?.isMoving || newValues.rotation !== oldValues?.rotation) {
updateSprite()
}
} }
) )
onMounted(async () => { onMounted(async () => {
await initializeSprite() const characterTypeStorage = new CharacterTypeStorage()
const spriteId = await characterTypeStorage.getSpriteId(props.mapCharacter.character.characterType!)
if (!spriteId) return
charSpriteId.value = spriteId
await loadSpriteTextures(scene, spriteId)
charSprite.value!.setTexture(charTexture.value)
charSprite.value!.setFlipX(isFlippedX.value)
charContainer.value!.setName(props.mapCharacter.character!.name)
if (props.mapCharacter.character.id === gameStore.character!.id) { if (props.mapCharacter.character.id === gameStore.character!.id) {
scene.cameras.main.startFollow(characterContainer.value as Phaser.GameObjects.Container) mapStore.setCharacterLoaded(true)
// #146 : Set camera position to character, need to be improved still
scene.cameras.main.startFollow(charContainer.value as Phaser.GameObjects.Container)
} }
updatePosition(props.mapCharacter.character.positionX, props.mapCharacter.character.positionY, props.mapCharacter.character.rotation)
}) })
onUnmounted(() => { onUnmounted(() => {
cleanup() tween.value?.stop()
}) })
</script> </script>

View File

@ -0,0 +1,51 @@
<template>
<Image v-bind="imageProps" v-if="gameStore.getLoadedAsset(texture)" />
</template>
<script lang="ts" setup>
import type { MapCharacter, Sprite as SpriteT } from '@/application/types'
import { loadSpriteTextures } from '@/composables/gameComposable'
import { useGameStore } from '@/stores/gameStore'
import { Image, useScene } from 'phavuer'
import { computed } from 'vue'
const props = defineProps<{
mapCharacter: MapCharacter
currentX: number
currentY: number
}>()
const gameStore = useGameStore()
const scene = useScene()
const texture = computed(() => {
const { rotation, characterHair } = props.mapCharacter.character
const spriteId = characterHair?.sprite?.id
const direction = [0, 6].includes(rotation) ? 'back' : 'front'
return `${spriteId}-${direction}`
})
const isFlippedX = computed(() => [6, 4].includes(props.mapCharacter.character.rotation ?? 0))
const imageProps = computed(() => {
// Get the current sprite action based on direction
const direction = [0, 6].includes(props.mapCharacter.character.rotation ?? 0) ? 'back' : 'front'
const spriteAction = props.mapCharacter.character.characterHair?.sprite?.spriteActions?.find((spriteAction) => spriteAction.action === direction)
return {
depth: 1,
originX: Number(spriteAction?.originX) ?? 0,
originY: Number(spriteAction?.originY) ?? 0,
flipX: isFlippedX.value,
texture: texture.value
// y: props.mapCharacter.isMoving ? Math.floor(Date.now() / 250) % 2 : 0
}
})
loadSpriteTextures(scene, props.mapCharacter.character.characterHair?.sprite as SpriteT)
.then(() => {})
.catch((error) => {
console.error('Error loading texture:', error)
})
</script>

View File

@ -1,63 +1,51 @@
<template> <template>
<Image ref="image" v-if="hairSpriteId" /> <Image v-bind="imageProps" v-if="gameStore.getLoadedAsset(texture)" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { MapCharacter, Sprite as SpriteT } from '@/application/types' import type { MapCharacter, Sprite as SpriteT } from '@/application/types'
import { loadSpriteTextures } from '@/services/textureService' import { loadSpriteTextures } from '@/composables/gameComposable'
import { CharacterHairStorage, CharacterTypeStorage, SpriteStorage } from '@/storage/storages' import { useGameStore } from '@/stores/gameStore'
import { Image, refObj, useScene } from 'phavuer' import { Image, useScene } from 'phavuer'
import { computed, onMounted, ref, watch } from 'vue' import { computed } from 'vue'
const props = defineProps<{ const props = defineProps<{
mapCharacter: MapCharacter mapCharacter: MapCharacter
currentX: number
currentY: number
}>() }>()
const gameStore = useGameStore()
const scene = useScene() const scene = useScene()
const hairSpriteId = ref('')
const hairSprite = ref<SpriteT | null>(null)
const characterSpriteHeight = ref(0)
const image = refObj<Phaser.GameObjects.Image>()
const flipX = computed(() => [6, 0].includes(props.mapCharacter.character.rotation ?? 0))
const texture = computed(() => { const texture = computed(() => {
const direction = flipX.value ? 'back' : 'front' const { rotation, characterHair } = props.mapCharacter.character
const spriteId = characterHair?.sprite?.id
const direction = [0, 6].includes(rotation) ? 'back' : 'front'
return `${hairSpriteId.value}-${direction}` return `${spriteId}-${direction}`
}) })
watch( const isFlippedX = computed(() => [6, 4].includes(props.mapCharacter.character.rotation ?? 0))
() => props.mapCharacter.character,
(newValue) => {
if (!image.value) return
image.value.setTexture(texture.value)
},
{ deep: true }
)
onMounted(async () => { const imageProps = computed(() => {
if (!props.mapCharacter.character.characterType || !props.mapCharacter.character.characterHair) return // Get the current sprite action based on direction
const direction = [0, 6].includes(props.mapCharacter.character.rotation ?? 0) ? 'back' : 'front'
const spriteAction = props.mapCharacter.character.characterHair?.sprite?.spriteActions?.find((spriteAction) => spriteAction.action === direction)
const characterTypeStorage = new CharacterTypeStorage() return {
const characterHairStorage = new CharacterHairStorage() depth: 1,
const spriteStorage = new SpriteStorage() originX: Number(spriteAction?.originX) ?? 0,
originY: Number(spriteAction?.originY) ?? 0,
const characterType = await characterTypeStorage.getById(props.mapCharacter.character.characterType!) flipX: isFlippedX.value,
if (!characterType) return texture: texture.value,
characterSpriteHeight.value = 100 y: props.mapCharacter.isMoving ? Math.floor(Date.now() / 250) % 2 : 0
}
hairSpriteId.value = await characterHairStorage.getSpriteId(props.mapCharacter.character.characterHair)
if (!hairSpriteId.value) return
hairSprite.value = await spriteStorage.getById(hairSpriteId.value)
if (!hairSprite.value) return
await loadSpriteTextures(scene, hairSpriteId.value)
if (!image.value) return
image.value.setOrigin(0.5, 2.15)
image.value.setTexture(texture.value)
image.value.setSize(30, 40)
}) })
loadSpriteTextures(scene, props.mapCharacter.character.characterHair?.sprite as SpriteT)
.then(() => {})
.catch((error) => {
console.error('Error loading texture:', error)
})
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<Container ref="characterChatContainer"> <Container ref="charChatContainer" :depth="999" :x="currentX" :y="currentY">
<RoundRectangle @create="createChatBubble" :origin-x="0.5" :origin-y="7.5" :fillColor="0xffffff" :width="194" :height="21" :radius="20" /> <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' }" /> <Text @create="createChatText" :style="{ fontSize: 13, fontFamily: 'Arial', color: '#000' }" />
</Container> </Container>
@ -12,10 +12,12 @@ import { onMounted } from 'vue'
const props = defineProps<{ const props = defineProps<{
mapCharacter: MapCharacter mapCharacter: MapCharacter
currentX: number
currentY: number
}>() }>()
const game = useGame() const game = useGame()
const characterChatContainer = refObj<Phaser.GameObjects.Container>() const charChatContainer = refObj<Phaser.GameObjects.Container>()
const createChatBubble = (container: Phaser.GameObjects.Container) => { const createChatBubble = (container: Phaser.GameObjects.Container) => {
container.setName(`${props.mapCharacter.character.name}_chatBubble`) container.setName(`${props.mapCharacter.character.name}_chatBubble`)
@ -39,7 +41,7 @@ const createChatText = (text: Phaser.GameObjects.Text) => {
} }
onMounted(() => { onMounted(() => {
characterChatContainer.value!.setName(`${props.mapCharacter.character!.name}_chatContainer`) charChatContainer.value!.setName(`${props.mapCharacter.character!.name}_chatContainer`)
characterChatContainer.value!.setVisible(false) charChatContainer.value!.setVisible(false)
}) })
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<Container :depth="999"> <Container :depth="999" :x="currentX" :y="currentY">
<Text @create="createNicknameText" :text="props.mapCharacter.character.name" /> <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="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" /> <RoundRectangle :origin-x="0.5" :origin-y="36.4" :fillColor="0x00b3b3" :width="70" :height="3" :radius="5" />
@ -12,6 +12,8 @@ import { Container, RoundRectangle, Text, useGame } from 'phavuer'
const props = defineProps<{ const props = defineProps<{
mapCharacter: MapCharacter mapCharacter: MapCharacter
currentX: number
currentY: number
}>() }>()
const game = useGame() const game = useGame()

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="absolute" v-if="gameStore.uiSettings.isCharacterProfileOpen" :style="modalStyle"> <div class="absolute" v-if="gameStore.uiSettings.isCharacterProfileOpen" :style="modalStyle">
<div class="relative"> <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-outer.svg" class="absolute w-full h-full" />
<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="" /> <img src="/assets/ui-elements/profile-ui-box-inner.svg" class="absolute left-2 bottom-2 w-[calc(100%_-_16px)] h-[calc(100%_-_40px)]" />
<div @mousedown="startDrag" class="cursor-move px-5 py-2.5 flex justify-between items-center relative"> <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> <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"> <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" /> <img alt="close" draggable="false" src="/assets/icons/modal/close-button-white.svg" class="w-full h-full" />
</button> </button>
</div> </div>
<div class="py-4 px-6 flex flex-col gap-7 relative z-10"> <div class="py-4 px-6 flex flex-col gap-7 relative z-10">
@ -17,7 +17,7 @@
<span class="text-xs">{{ gameStore.character?.experience }} / 18.600XP</span> <span class="text-xs">{{ gameStore.character?.experience }} / 18.600XP</span>
</div> </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]"> <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" /> <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" />
</a> </a>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
@ -37,20 +37,20 @@
</div> </div>
</div> </div>
</div> </div>
<img src="/assets/placeholders/inventory_player.png" class="w-8 h-auto" alt="Player character sprite" /> <img src="/assets/placeholders/inventory_player.png" class="w-8 h-auto" />
<div class="flex flex-col items-end gap-0.5"> <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"> <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" /> <img class="w-6 h-6 center-element" src="/assets/icons/profile/helmet.svg" />
</div> </div>
<div class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600"> <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" /> <img class="w-6 h-6 center-element" src="/assets/icons/profile/chestplate.svg" />
</div> </div>
<div class="flex gap-0.5 items-end"> <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"> <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" /> <img class="w-4 h-4 center-element" src="/assets/icons/profile/boots.svg" />
</div> </div>
<div class="w-9 h-9 default-border rounded-sm bg-gray relative hover:bg-gray-600"> <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" /> <img class="w-6 h-6 center-element" src="/assets/icons/profile/legs.svg" />
</div> </div>
</div> </div>
</div> </div>
@ -119,44 +119,111 @@
<script setup lang="ts"> <script setup lang="ts">
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const gameStore = useGameStore() const gameStore = useGameStore()
const width = ref(286) let startX = 0
const height = ref(483) let startY = 0
const x = ref(window.innerWidth / 2 - 143) let initialX = 0
const y = ref(window.innerHeight / 2 - 241) let initialY = 0
let modalPositionX = 0
let modalPositionY = 0
let modalWidth = 286
let modalHeight = 483
const width = ref(modalWidth)
const height = ref(modalHeight)
const x = ref(0)
const y = ref(0)
const isDragging = ref(false) const isDragging = ref(false)
const modalStyle = computed(() => ({ const modalStyle = computed(() => ({
top: `${y.value}px`, top: `${y.value}px`,
left: `${x.value}px`, left: `${x.value}px`,
width: `${width.value}px`, width: `${width.value}px`,
height: `${height.value}px` height: `${height.value}px`,
maxWidth: '100vw',
maxHeight: '100vh'
})) }))
function startDrag(event: MouseEvent) { function startDrag(event: MouseEvent) {
isDragging.value = true isDragging.value = true
const startX = event.clientX - x.value startX = event.clientX
const startY = event.clientY - y.value startY = event.clientY
initialX = x.value
function drag(event: MouseEvent) { initialY = y.value
if (!isDragging.value) return event.preventDefault()
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 drag(event: MouseEvent) {
if (!isDragging.value) return
const dx = event.clientX - startX
const dy = event.clientY - startY
x.value = initialX + dx
y.value = initialY + dy
adjustPosition()
}
function stopDrag() {
isDragging.value = false
}
function adjustPosition() {
x.value = Math.min(x.value, window.innerWidth - width.value)
y.value = Math.min(y.value, window.innerHeight - height.value)
}
function initializePosition() {
width.value = Math.min(modalWidth, window.innerWidth)
height.value = Math.min(modalHeight, window.innerHeight)
if (modalPositionX !== 0 && modalPositionY !== 0) {
x.value = modalPositionX
y.value = modalPositionY
} else {
x.value = (window.innerWidth - width.value) / 2
y.value = (window.innerHeight - height.value) / 2
}
}
watch(
() => gameStore.uiSettings.isCharacterProfileOpen,
(value) => {
gameStore.uiSettings.isCharacterProfileOpen = value
if (value) {
initializePosition()
}
}
)
watch(
() => modalWidth,
(value) => {
width.value = Math.min(value, window.innerWidth)
}
)
watch(
() => modalHeight,
(value) => {
height.value = Math.min(value, window.innerHeight)
}
)
watch(
() => modalPositionX,
(value) => {
x.value = value
}
)
watch(
() => modalPositionY,
(value) => {
y.value = value
}
)
function keyPress(event: KeyboardEvent) { function keyPress(event: KeyboardEvent) {
if (event.altKey && event.key === 'c') { if (event.altKey && event.key === 'c') {
gameStore.toggleCharacterProfile() gameStore.toggleCharacterProfile()
@ -165,9 +232,14 @@ function keyPress(event: KeyboardEvent) {
onMounted(() => { onMounted(() => {
addEventListener('keydown', keyPress) addEventListener('keydown', keyPress)
addEventListener('mousemove', drag)
addEventListener('mouseup', stopDrag)
initializePosition()
}) })
onUnmounted(() => { onUnmounted(() => {
removeEventListener('keydown', keyPress) removeEventListener('keydown', keyPress)
removeEventListener('mousemove', drag)
removeEventListener('mouseup', stopDrag)
}) })
</script> </script>

View File

@ -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 }}</span> <span class="text-ellipsis overflow-hidden whitespace-nowrap text-sm" :class="{ 'text-cyan-300': gameStore.character?.role == 'gm' }">{{ message.character.name }}</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" alt="" /> <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" />
<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,8 +21,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import type { Chat } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore' import { useMapStore } from '@/stores/mapStore'
import { onClickOutside, useFocus } from '@vueuse/core' import { onClickOutside, useFocus } from '@vueuse/core'
@ -31,9 +30,10 @@ import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const scene = useScene() const scene = useScene()
const gameStore = useGameStore() const gameStore = useGameStore()
const mapStore = useMapStore()
const message = ref('') const message = ref('')
const chats = ref<{ character: string; message: string }[]>([]) const chats = ref([] as Chat[])
const chatWindow = ref<HTMLElement | null>(null) const chatWindow = ref<HTMLElement | null>(null)
const chatInput = ref<HTMLElement | null>(null) const chatInput = ref<HTMLElement | null>(null)
@ -55,7 +55,7 @@ function unfocusChat(event: Event, targetElement: HTMLElement) {
const sendMessage = () => { const sendMessage = () => {
if (!message.value.trim()) return if (!message.value.trim()) return
socketManager.emit(SocketEvent.CHAT_MESSAGE, { message: message.value }, (response: boolean) => {}) gameStore.connection?.emit('chat:message', { message: message.value }, (response: boolean) => {})
message.value = '' message.value = ''
} }
@ -79,30 +79,18 @@ const scrollToBottom = () => {
}) })
} }
socketManager.on(SocketEvent.CHAT_MESSAGE, (data: { character: string; message: string }) => { gameStore.connection?.on('chat:message', (data: Chat) => {
if (!data.character || !data.message) return chats.value.push(data)
chats.value.push({ character: data.character, message: data.message })
scrollToBottom() scrollToBottom()
const characterContainer = scene.children.getByName(data.character) as Phaser.GameObjects.Container if (!mapStore.characterLoaded) return
if (!characterContainer) {
console.log('No character container found')
return
}
const characterChatContainer = characterContainer.getByName(data.character + '_chatContainer') as Phaser.GameObjects.Container const charChatContainer = scene.children.getByName(data.character.name + '_chatContainer') as Phaser.GameObjects.Container
if (!characterChatContainer) { if (!charChatContainer) return
console.log('No character chat container found')
return
}
const chatBubble = characterChatContainer.getByName(data.character + '_chatBubble') as Phaser.GameObjects.Container const chatBubble = charChatContainer.getByName(data.character.name + '_chatBubble') as Phaser.GameObjects.Container
const chatText = characterChatContainer.getByName(data.character + '_chatText') as Phaser.GameObjects.Text const chatText = charChatContainer.getByName(data.character.name + '_chatText') as Phaser.GameObjects.Text
if (!chatText || !chatBubble) { if (!chatText || !chatBubble) return
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
@ -127,24 +115,24 @@ socketManager.on(SocketEvent.CHAT_MESSAGE, (data: { character: string; message:
// 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))
characterChatContainer.setVisible(true) charChatContainer.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 (characterChatContainer.getData('hideTimer')) { if (charChatContainer.getData('hideTimer')) {
clearTimeout(characterChatContainer.getData('hideTimer')) clearTimeout(charChatContainer.getData('hideTimer'))
} }
// Set a new hide timer // Set a new hide timer
const hideTimer = setTimeout(() => { const hideTimer = setTimeout(() => {
characterChatContainer.setVisible(false) charChatContainer.setVisible(false)
}, 3000) }, 3000)
// Store the timer on the container itself // Store the timer on the container itself
characterChatContainer.setData('hideTimer', hideTimer) charChatContainer.setData('hideTimer', hideTimer)
}) })
scrollToBottom() scrollToBottom()
@ -153,7 +141,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
socketManager.off(SocketEvent.CHAT_MESSAGE) gameStore.connection?.off('chat:message')
removeEventListener('keydown', focusChat) removeEventListener('keydown', focusChat)
}) })
</script> </script>

View File

@ -1,21 +1,21 @@
<template> <template>
<div class="absolute top-0 right-4 hidden lg:block" v-if="gameStore.world.date && typeof gameStore.world.date === 'object'"> <div class="absolute top-0 right-4 hidden lg:block">
<p class="text-white text-lg"> <p class="text-white text-lg">{{ gameStore.world.date.toLocaleString() }}</p>
{{ useDateFormat(gameStore.world.date, 'YYYY/MM/DD HH:mm') }}
</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useDateFormat } from '@vueuse/core'
import { onUnmounted } from 'vue' import { onUnmounted } from 'vue'
const gameStore = useGameStore() const gameStore = useGameStore()
// Listen for new date from socket
gameStore.connection?.on('date', (data: Date) => {
gameStore.world.date = new Date(data)
})
onUnmounted(() => { onUnmounted(() => {
socketManager.off(SocketEvent.DATE) gameStore.connection?.off('date')
}) })
</script> </script>

View File

@ -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" alt="Menu button icon" /> <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" />
</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/placeholders/head.png" alt="User profile button icon" /> <img class="group-hover:drop-shadow-default w-8 h-8 m-[5px] object-contain" draggable="false" src="/assets/placeholders/head.png" />
<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>
@ -25,7 +25,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:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border-2 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" alt="Open chat button icon" /> <img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/chat-icon.svg" />
</a> </a>
</li> </li>
<li class="menu-item group relative"> <li class="menu-item group relative">
@ -34,7 +34,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:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border-2 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" alt="World map button icon" /> <img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/map-icon.svg" />
</a> </a>
</li> </li>
<li class="menu-item group relative"> <li class="menu-item group relative">
@ -43,7 +43,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:bg-gray-800 bg-gray-900 group-hover:cursor-pointer border-2 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" alt="Users button icon" /> <img class="group-hover:drop-shadow-default w-6 h-6 m-[5px] object-contain" draggable="false" src="/assets/icons/ingameUI/socials-icon.svg" />
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -5,12 +5,12 @@
</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="w-3 h-3 center-element" src="/assets/icons/plus-icon.svg" alt="Zoom-in button icon" /> <img class="w-3 h-3 center-element" src="/assets/icons/plus-icon.svg" />
<img class="w-full h-full" src="/assets/ui-elements/button-ui-box-textured.svg" alt="" /> <img class="w-full h-full" src="/assets/ui-elements/button-ui-box-textured.svg" />
</button> </button>
<button class="w-6 h-6 relative p-0"> <button class="w-6 h-6 relative p-0">
<img class="w-3 h-3 center-element" src="/assets/icons/minus-icon.svg" alt="Zoom-out button icon" /> <img class="w-3 h-3 center-element" src="/assets/icons/minus-icon.svg" />
<img class="w-full h-full" src="/assets/ui-elements/button-ui-box-textured.svg" alt="" /> <img class="w-full h-full" src="/assets/ui-elements/button-ui-box-textured.svg" />
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,42 @@
<template>
<div class="absolute z-50 w-full h-dvh top-0 left-0 bg-black/60" v-show="false">
<div class="center-element max-w-[875px] max-h-[600px] h-full w-[80%] bg-gray border-solid border-2 border-gray-500 rounded-md z-50 flex flex-col backdrop-blur-sm shadow-lg">
<div class="p-2.5 flex max-sm:flex-wrap justify-between items-center gap-5 border-solid border-0 border-b border-gray-500">
<h3 class="m-0 font-medium shrink-0">Game menu</h3>
<div class="hidden sm:flex gap-1.5 flex-wrap">
<button @click.stop="userPanelScreen = 'inventory'" :class="{ active: userPanelScreen === 'inventory' }" class="btn-cyan py-1.5 px-4 min-w-24">Inventory</button>
<button @click.stop="userPanelScreen = 'equipment'" :class="{ active: userPanelScreen === 'equipment' }" class="btn-cyan py-1.5 px-4 min-w-24">Equipment</button>
<button @click.stop="userPanelScreen = 'characterScreen'" :class="{ active: userPanelScreen === 'characterScreen' }" class="btn-cyan py-1.5 px-4 min-w-24">Character</button>
<button @click.stop="userPanelScreen = 'settings'" :class="{ active: userPanelScreen === 'settings' }" class="btn-cyan py-1.5 px-4 min-w-24">Settings</button>
</div>
<div class="flex gap-2.5">
<button class="w-3.5 h-3.5 m-0 p-0 relative hover:rotate-180 transition-transform duration-300 ease-in-out">
<img alt="close" draggable="false" src="/assets/icons/modal/close-button-white.svg" class="w-full h-full" />
</button>
</div>
<div class="flex sm:hidden gap-1.5 flex-wrap">
<button @click.stop="userPanelScreen = 'inventory'" :class="{ active: userPanelScreen === 'inventory' }" class="btn-cyan py-1.5 px-4 min-w-24">Inventory</button>
<button @click.stop="userPanelScreen = 'equipment'" :class="{ active: userPanelScreen === 'equipment' }" class="btn-cyan py-1.5 px-4 min-w-24">Equipment</button>
<button @click.stop="userPanelScreen = 'characterScreen'" :class="{ active: userPanelScreen === 'characterScreen' }" class="btn-cyan py-1.5 px-4 min-w-24">Character</button>
<button @click.stop="userPanelScreen = 'settings'" :class="{ active: userPanelScreen === 'settings' }" class="btn-cyan py-1.5 px-4 min-w-24">Settings</button>
</div>
</div>
<Inventory v-show="userPanelScreen === 'inventory'" />
<Equipment v-show="userPanelScreen === 'equipment'" />
<CharacterScreen v-show="userPanelScreen === 'characterScreen'" />
<Settings v-show="userPanelScreen === 'settings'" />
</div>
</div>
</template>
<script setup lang="ts">
import CharacterScreen from '@/components/game/gui/partials/CharacterScreen.vue'
import Equipment from '@/components/game/gui/partials/Equipment.vue'
import Inventory from '@/components/game/gui/partials/Inventory.vue'
import Settings from '@/components/game/gui/partials/Settings.vue'
import { useGameStore } from '@/stores/gameStore'
import { ref } from 'vue'
const gameStore = useGameStore()
let userPanelScreen = ref('inventory')
</script>

View File

@ -0,0 +1,68 @@
<template>
<div class="grow flex flex-col w-full h-full relative overflow-auto">
<div class="m-4 relative">
<h4 class="font-medium text-lg max-w-[375px]">Character</h4>
<div class="flex justify-center flex-wrap gap-20">
<div class="flex gap-3 mt-2 flex-wrap max-w-[375px]">
<div class="h-full flex flex-col justify-center items-center">
<img class="h-72 my-2 mx-auto" src="/assets/placeholders/inventory_player.png" />
</div>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 mx-5 mt-2">
<h3>{{ gameStore.character?.name }}</h3>
<div class="flex gap-4 flex-wrap max-w-[375px]">
<ul class="p-0 m-0 list-none">
<li class="leading-6 text-lg">Class:</li>
<li class="leading-6 text-lg">Race:</li>
<li class="leading-6 text-lg">Hit Points:</li>
<li class="leading-6 text-lg">Mana Points:</li>
<li class="leading-6 text-lg">Level:</li>
<li class="leading-6 text-lg">Stat Points:</li>
</ul>
<ul class="p-0 m-0 list-none">
<li class="leading-6 text-lg min-h-6">Knight</li>
<li class="leading-6 text-lg min-h-6">{{ gameStore.character?.characterType?.race }}</li>
<li class="leading-6 text-lg min-h-6">{{ gameStore.character?.hitpoints }} <span class="text-green">(+15)</span></li>
<li class="leading-6 text-lg min-h-6">{{ gameStore.character?.mana }}</li>
<li class="leading-6 text-lg min-h-6">{{ gameStore.character?.level }}</li>
<li class="leading-6 text-lg min-h-6">3</li>
</ul>
</div>
</div>
<div class="flex flex-col gap-3 mx-5 mt-2">
<h3>Alignment</h3>
<div class="h-8 w-64 rounded border border-solid border-white bg-gradient-to-r from-red to-blue relative">
<!-- TODO: Give slider left value based on alignment (0-100), new characters start with 50 -->
<div class="rounded border-2 border-solid border-white h-10 w-2 absolute top-1/2 -translate-y-1/2 -translate-x-1/2" :style="{ left: gameStore.character?.alignment + '%' }"></div>
</div>
</div>
</div>
</div>
<div class="absolute -left-5 -bottom-5 w-[calc(100%_+_40px)] my-px h-px bg-gray-500"></div>
</div>
<div class="m-4">
<h4 class="font-medium text-lg max-w-[375px]">Character stats</h4>
<div class="flex gap-3 mt-4 flex-wrap max-w-[375px]">
<ul class="p-0 m-0 list-none">
<li class="leading-6">Health:</li>
<li class="leading-6">Defense:</li>
<li class="leading-6">More stats:</li>
<li class="leading-6">Extra stats:</li>
</ul>
<ul class="p-0 m-0 list-none text-right">
<li class="leading-6">100 <span class="text-green">(+15)</span></li>
<li class="leading-6">1000 <span class="text-green">(+500)</span></li>
<li class="leading-6"></li>
<li class="leading-6"></li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
const gameStore = useGameStore()
</script>

View File

@ -0,0 +1,89 @@
<template>
<div class="grow flex flex-col w-full h-full relative overflow-auto">
<div class="m-4 relative">
<h4 class="font-medium text-lg max-w-[375px]">Equipment</h4>
<div class="flex justify-center items-center flex-wrap gap-20">
<div class="flex gap-3 mt-2 flex-wrap max-w-[375px]">
<div class="h-full flex flex-col justify-center items-center">
<h3>{{ gameStore.character?.name }}</h3>
<img class="h-60 my-2 mx-auto" src="/assets/placeholders/inventory_player.png" />
<span class="block text-sm">Level {{ gameStore.character?.level }}</span>
</div>
</div>
<div class="flex flex-col gap-3 mx-5 mt-2">
<div class="flex gap-3 justify-center">
<!-- Helmet -->
<div class="bg-gray-300/80 border-solid border-2 border-gray-500 rounded-md aspect-square h-11 w-11 relative hover:bg-gray-200">
<img src="/assets/icons/inventory/helmet.svg" class="center-element w-11/12 opacity-20" />
</div>
<!-- Head charm -->
<div class="bg-gray-300/80 border-solid border-2 border-gray-500 rounded-md aspect-square h-11 w-11 relative hover:bg-gray-200">
<img src="/assets/icons/inventory/head_charm.svg" class="center-element w-11/12 opacity-20" />
</div>
</div>
<div class="flex gap-3 justify-center">
<!-- Bracers -->
<div class="bg-gray-300/80 border-solid border-2 border-gray-500 rounded-md w-11 h-[104px] relative hover:bg-gray-200">
<img src="/assets/icons/inventory/bracers.svg" class="center-element w-11/12 opacity-20" />
</div>
<!-- Chestplate -->
<div class="bg-gray-300/80 border-solid border-2 border-gray-500 rounded-md aspect-square w-[104px] h-[104px] relative hover:bg-gray-200">
<img src="/assets/icons/inventory/chestplate.svg" class="center-element w-10/12 opacity-20" />
</div>
<!-- Primary Weapon -->
<div class="bg-gray-300/80 border-solid border-2 border-gray-500 rounded-md w-11 h-[104px] self-stretch justify-self-stretch relative hover:bg-gray-200">
<img src="/assets/icons/inventory/primary_weapon.svg" class="center-element w-11/12 opacity-20" />
</div>
</div>
<div class="flex gap-3 justify-center">
<!-- Legs -->
<div class="bg-gray-300/80 border-solid border-2 border-gray-500 rounded-md w-11 h-[104px] self-stretch justify-self-stretch relative hover:bg-gray-200">
<img src="/assets/icons/inventory/legs.svg" class="center-element w-11/12 opacity-20" />
</div>
<div class="flex flex-col gap-3">
<!-- Belt/pouch -->
<div class="bg-gray-300/80 border-solid border-2 border-gray-500 rounded-md aspect-square h-11 w-11 self-stretch justify-self-stretch relative hover:bg-gray-200">
<img src="/assets/icons/inventory/pouch.svg" class="center-element w-11/12 opacity-20" />
</div>
<!-- Boots -->
<div class="bg-gray-300/80 border-solid border-2 border-gray-500 rounded-md aspect-square h-11 w-11 self-stretch justify-self-stretch relative hover:bg-gray-200">
<img src="/assets/icons/inventory/boots.svg" class="center-element w-11/12 opacity-20" />
</div>
</div>
</div>
</div>
</div>
<div class="absolute -left-5 -bottom-5 w-[calc(100%_+_40px)] my-px h-px bg-gray-500"></div>
</div>
<div class="m-4">
<h4 class="font-medium text-lg max-w-[375px]">Equipment Bonus</h4>
<div class="flex gap-3 mt-4 flex-wrap max-w-[375px]">
<ul class="p-0 m-0 list-none">
<li class="leading-6">Health:</li>
<li class="leading-6">Defense:</li>
<li class="leading-6">More stats:</li>
<li class="leading-6">Extra stats:</li>
</ul>
<ul class="p-0 m-0 list-none text-right">
<li class="leading-6">+15</li>
<li class="leading-6">500</li>
<li class="leading-6"></li>
<li class="leading-6"></li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
const gameStore = useGameStore()
</script>

View File

@ -0,0 +1,17 @@
<template>
<div class="grow flex flex-col w-full h-full relative overflow-auto">
<div class="m-4 relative">
<h4 class="m-auto font-medium text-lg max-w-[375px]">Inventory</h4>
<div class="flex gap-3 mt-4 mx-auto flex-wrap max-w-[375px]">
<div v-for="n in 24" class="bg-gray-300/80 border-solid border-2 border-gray-500 w-12 h-12 rounded-md aspect-square shrink-0 justify-self-stretch hover:bg-gray-200"></div>
</div>
<div class="absolute -left-5 -bottom-5 w-[calc(100%_+_40px)] my-px h-px bg-gray-500"></div>
</div>
<div class="m-4">
<h4 class="m-auto font-medium text-lg max-w-[375px]">Chest items</h4>
<div class="flex gap-3 mt-4 mx-auto flex-wrap max-w-[375px]">
<div v-for="n in 12" class="bg-gray-300/80 border-solid border-2 border-gray-500 w-12 h-12 rounded-md aspect-square shrink-0 justify-self-stretch hover:bg-gray-200"></div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,40 @@
<template>
<div class="flex h-full w-full relative">
<div class="w-2/12 flex flex-col relative">
<!-- Settings Categories -->
<div class="relative p-2.5">
<h3>Settings</h3>
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
</div>
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': settingCategory === 'character' }" @click.stop="settingCategory = 'character'">
<span>Character</span>
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
</a>
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': settingCategory === 'account' }" @click.stop="settingCategory = 'account'">
<span>Account</span>
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
</a>
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': settingCategory === 'audio' }" @click.stop="settingCategory = 'audio'">
<span>Audio</span>
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
</a>
<a class="relative p-2.5 hover:cursor-pointer" :class="{ 'bg-cyan/80': settingCategory === 'video' }" @click.stop="settingCategory = 'video'">
<span>Video</span>
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
</a>
</div>
<div class="absolute w-px bg-gray-500 h-full top-0 left-1/6"></div>
<!-- Assets list -->
<div class="overflow-auto h-full w-10/12 flex flex-col relative">
<CharacterSettings v-show="settingCategory == 'character'" />
</div>
</div>
</template>
<script setup lang="ts">
import CharacterSettings from '@/components/game/gui/partials/settings/CharacterSettings.vue'
import { ref } from 'vue'
let settingCategory = ref('character')
</script>

View File

@ -0,0 +1,34 @@
<template>
<div class="h-full overflow-auto">
<div class="relative p-2.5 flex flex-col gap-5 h-72">
<h3>Character details</h3>
<button @click="toggle" class="btn-cyan px-4 py-1.5 w-24">Edit</button>
<form class="flex gap-2.5 flex-wrap">
<div class="form-field-half max-w-[300px]">
<label for="name">Name</label>
<input class="input-field" :class="{ inactive: !editCharacter }" type="text" name="name" placeholder="Ethereal" :disabled="!editCharacter" />
</div>
<div class="form-field-half max-w-[300px] relative">
<label for="class">Class</label>
<select class="input-field" v-model="characterClass" :class="{ inactive: !editCharacter }" name="class" :disabled="!editCharacter">
<option value="Knight" :selected="characterClass == 'Knight'" :disabled="characterClass == 'Knight'">Knight</option>
<option value="Paladin" :selected="characterClass == 'Paladin'" :disabled="characterClass == 'Paladin'">Paladin</option>
</select>
<span v-if="!editCharacter" class="absolute bottom-[9px] left-[14px] text-sm text-gray-300/50">{{ characterClass }}</span>
</div>
<button v-if="editCharacter" @click="toggle" class="btn-cyan px-4 py-1.5 min-w-24" type="submit">Save</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const editCharacter = ref(false)
const characterClass = ref('')
const toggle = () => {
editCharacter.value = !editCharacter.value
}
</script>

View File

@ -1,49 +1,14 @@
<template> <template>
<Character v-for="item in mapStore.characters" :key="item.character.id" :tileMap :mapCharacter="item" /> <Character v-for="item in mapStore.characters" :key="item.character.id" :tilemap="tilemap" :mapCharacter="item" />
</template> </template>
<script setup lang="ts"> <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 Character from '@/components/game/character/Character.vue'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore' import { useMapStore } from '@/stores/mapStore'
import { onUnmounted } from 'vue'
const gameStore = useGameStore()
const mapStore = useMapStore() const mapStore = useMapStore()
const props = defineProps<{ const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap tilemap: Phaser.Tilemaps.Tilemap
}>() }>()
socketManager.on(SocketEvent.MAP_CHARACTER_JOIN, (data: MapCharacter) => {
mapStore.addCharacter(data)
})
socketManager.on(SocketEvent.MAP_CHARACTER_LEAVE, (characterId: UUID) => {
mapStore.removeCharacter(characterId)
})
socketManager.on(SocketEvent.MAP_CHARACTER_MOVE, ([characterId, posX, posY, rot, isMoving]: [UUID, number, number, number, boolean]) => {
mapStore.updateCharacterPosition([characterId, posX, posY, rot, isMoving])
if (characterId === gameStore.character?.id) {
gameStore.character!.positionX = posX
gameStore.character!.positionY = posY
gameStore.character!.rotation = rot
}
})
socketManager.on(SocketEvent.MAP_CHARACTER_ATTACK, (characterId: UUID) => {
mapStore.updateCharacterProperty(characterId, 'isAttacking', true)
})
onUnmounted(() => {
socketManager.off(SocketEvent.MAP_CHARACTER_ATTACK)
socketManager.off(SocketEvent.MAP_CHARACTER_MOVE)
socketManager.off(SocketEvent.MAP_CHARACTER_JOIN)
socketManager.off(SocketEvent.MAP_CHARACTER_LEAVE)
})
</script> </script>

View File

@ -0,0 +1,199 @@
<template>
<Scene name="effects" @preload="preloadScene" @create="createScene" @update="updateScene" />
</template>
<script setup lang="ts">
import type { Map, WeatherState } from '@/application/types'
import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore'
import { Scene } from 'phavuer'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
// Constants
const LIGHT_CONFIG = {
SUNRISE_HOUR: 6,
SUNSET_HOUR: 20,
DAY_STRENGTH: 100,
NIGHT_STRENGTH: 30
}
// Stores and refs
const gameStore = useGameStore()
const mapStore = useMapStore()
const mapStorage = new MapStorage()
const sceneRef = ref<Phaser.Scene | null>(null)
const mapEffectsReady = ref(false)
const mapObject = ref<Map | null>(null)
// Effect objects
const effects = {
light: ref<Phaser.GameObjects.Graphics | null>(null),
rain: ref<Phaser.GameObjects.Particles.ParticleEmitter | null>(null),
fog: ref<Phaser.GameObjects.Sprite | null>(null)
}
// Weather state
const weatherState = ref<WeatherState>({
isRainEnabled: false,
rainPercentage: 0,
isFogEnabled: false,
fogDensity: 0
})
// Scene setup
const preloadScene = (scene: Phaser.Scene) => {
scene.load.image('raindrop', 'assets/raindrop.png')
scene.load.image('fog', 'assets/fog.png')
}
const createScene = (scene: Phaser.Scene) => {
sceneRef.value = scene
initializeEffects(scene)
setupSocketListeners()
}
const loadMap = async () => {
if (!mapStore.mapId) return
mapObject.value = await mapStorage.get(mapStore.mapId)
}
// Watch for mapId changes and load map when it's available
watch(
() => mapStore.mapId,
async (newMapId) => {
if (newMapId) {
mapEffectsReady.value = false
await loadMap()
updateScene()
}
},
{ immediate: true }
)
const initializeEffects = (scene: Phaser.Scene) => {
// Light
effects.light.value = scene.add.graphics().setDepth(1000)
// Rain
effects.rain.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'
})
.setDepth(900)
effects.rain.value.stop()
// Fog
effects.fog.value = scene.add
.sprite(window.innerWidth / 2, window.innerHeight / 2, 'fog')
.setScale(2)
.setAlpha(0)
.setDepth(950)
}
// Effect updates
const updateScene = () => {
const timeBasedLight = calculateLightStrength(gameStore.world.date)
const mapEffects = mapObject.value?.mapEffects?.reduce(
(acc, curr) => ({
...acc,
[curr.effect]: curr.strength
}),
{}
) as { [key: string]: number }
// Only update effects once mapEffects are loaded
if (!mapEffectsReady.value) {
if (mapObject.value) {
mapEffectsReady.value = true
} else {
return
}
}
const finalEffects =
mapEffects && Object.keys(mapEffects).length
? mapEffects
: {
light: timeBasedLight,
rain: weatherState.value.isRainEnabled ? weatherState.value.rainPercentage : 0,
fog: weatherState.value.isFogEnabled ? weatherState.value.fogDensity * 100 : 0
}
applyEffects(finalEffects)
}
const applyEffects = (effectValues: any) => {
if (effects.light.value) {
const darkness = 1 - (effectValues.light ?? 100) / 100
effects.light.value.clear().fillStyle(0x000000, darkness).fillRect(0, 0, window.innerWidth, window.innerHeight)
}
if (effects.rain.value) {
const strength = effectValues.rain ?? 0
strength > 0 ? effects.rain.value.start().setQuantity(Math.floor((strength / 100) * 10)) : effects.rain.value.stop()
}
if (effects.fog.value) {
effects.fog.value.setAlpha((effectValues.fog ?? 0) / 100)
}
}
const calculateLightStrength = (time: Date): number => {
const hour = time.getHours()
const minute = time.getMinutes()
if (hour >= LIGHT_CONFIG.SUNSET_HOUR || hour < LIGHT_CONFIG.SUNRISE_HOUR) return LIGHT_CONFIG.NIGHT_STRENGTH
if (hour > LIGHT_CONFIG.SUNRISE_HOUR && hour < LIGHT_CONFIG.SUNSET_HOUR - 2) return LIGHT_CONFIG.DAY_STRENGTH
if (hour === LIGHT_CONFIG.SUNRISE_HOUR) return LIGHT_CONFIG.NIGHT_STRENGTH + ((LIGHT_CONFIG.DAY_STRENGTH - LIGHT_CONFIG.NIGHT_STRENGTH) * minute) / 60
const totalMinutes = (hour - (LIGHT_CONFIG.SUNSET_HOUR - 2)) * 60 + minute
return LIGHT_CONFIG.DAY_STRENGTH - (LIGHT_CONFIG.DAY_STRENGTH - LIGHT_CONFIG.NIGHT_STRENGTH) * (totalMinutes / 120)
}
// Socket and window handlers
const setupSocketListeners = () => {
gameStore.connection?.emit('weather', (response: WeatherState) => {
weatherState.value = response
updateScene()
})
gameStore.connection?.on('weather', (data: WeatherState) => {
weatherState.value = data
updateScene()
})
gameStore.connection?.on('date', updateScene)
}
const handleResize = () => {
if (effects.rain.value) effects.rain.value.updateConfig({ x: { min: 0, max: window.innerWidth } })
if (effects.fog.value) effects.fog.value.setPosition(window.innerWidth / 2, window.innerHeight / 2)
}
// Lifecycle
watch(
() => mapObject.value,
() => {
mapEffectsReady.value = false
updateScene()
},
{ deep: true }
)
onMounted(() => window.addEventListener('resize', handleResize))
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (sceneRef.value) sceneRef.value.scene.remove('effects')
gameStore.connection?.off('weather')
})
</script>

View File

@ -1,71 +1,46 @@
<template> <template>
<MapTiles v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer /> <MapTiles :key="mapStore.mapId" @tileMap:create="tileMap = $event" />
<PlacedMapObjects v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer /> <PlacedMapObjects v-if="tileMap" :key="mapStore.mapId" :tilemap="tileMap" />
<Characters v-if="tileMap && mapStore.characters" :tileMap /> <Characters v-if="tileMap && mapStore.characters" :tilemap="tileMap" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import type { MapCharacter, mapLoadData, UUID } from '@/application/types'
import type { mapLoadData } from '@/application/types'
import { unduplicateArray } from '@/application/utilities'
import Characters from '@/components/game/map/Characters.vue' import Characters from '@/components/game/map/Characters.vue'
import MapTiles from '@/components/game/map/MapTiles.vue' import MapTiles from '@/components/game/map/MapTiles.vue'
import PlacedMapObjects from '@/components/game/map/PlacedMapObjects.vue' import PlacedMapObjects from '@/components/game/map/PlacedMapObjects.vue'
import { socketManager } from '@/managers/SocketManager'
import { createTileLayer, createTileMap, loadTileTexturesFromMapTileArray } from '@/services/mapService'
import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore' import { useMapStore } from '@/stores/mapStore'
import { useScene } from 'phavuer' import { onUnmounted, shallowRef } from 'vue'
import { onMounted, onUnmounted, shallowRef, watch } from 'vue'
const scene = useScene()
const gameStore = useGameStore() const gameStore = useGameStore()
const mapStore = useMapStore() const mapStore = useMapStore()
const mapStorage = new MapStorage()
const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>() const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
const tileMapLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
// Event listeners // Event listeners
socketManager.on(SocketEvent.MAP_CHARACTER_TELEPORT, (data: mapLoadData) => { gameStore.connection?.on('map:character:teleport', async (data: mapLoadData) => {
mapStore.setMapId(data.mapId) mapStore.setMapId(data.mapId)
mapStore.setCharacters(data.characters) mapStore.setCharacters(data.characters)
}) })
async function initialize() { gameStore.connection?.on('map:character:join', async (data: MapCharacter) => {
if (!mapStore.mapId) return mapStore.addCharacter(data)
})
const map = await mapStorage.getById(mapStore.mapId) gameStore.connection?.on('map:character:leave', (characterId: UUID) => {
if (!map) return mapStore.removeCharacter(characterId)
})
await loadTileTexturesFromMapTileArray(mapStore.mapId, scene) gameStore.connection?.on('map:character:move', (data: { characterId: UUID; positionX: number; positionY: number; rotation: number; isMoving: boolean }) => {
mapStore.updateCharacterPosition(data)
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(() => { onUnmounted(() => {
if (tileMap.value) { mapStore.reset()
tileMap.value.destroyLayer('tiles') gameStore.connection?.off('map:character:teleport')
tileMap.value.removeAllLayers() gameStore.connection?.off('map:character:join')
tileMap.value.destroy() gameStore.connection?.off('map:character:leave')
} gameStore.connection?.off('map:character:move')
socketManager.off(SocketEvent.MAP_CHARACTER_TELEPORT)
}) })
</script> </script>

View File

@ -1,32 +1,73 @@
<template> <template>
<Controls v-if="tileMapLayer" :layer="tileMapLayer" :depth="0" /> <Controls v-if="tileLayer" :layer="tileLayer" :depth="0" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config'
import type { UUID } from '@/application/types'
import { unduplicateArray } from '@/application/utilities'
import Controls from '@/components/utilities/Controls.vue' import Controls from '@/components/utilities/Controls.vue'
import { loadTileTexturesFromMapTileArray, placeTiles } from '@/services/mapService' import { FlattenMapArray, loadMapTilesIntoScene, setLayerTiles } from '@/composables/mapComposable'
import { MapStorage } from '@/storage/storages' import { MapStorage } from '@/storage/storages'
import { useMapStore } from '@/stores/mapStore' import { useMapStore } from '@/stores/mapStore'
import { useScene } from 'phavuer' import { useScene } from 'phavuer'
import { onMounted } from 'vue' import { onBeforeUnmount, shallowRef } from 'vue'
import Tileset = Phaser.Tilemaps.Tileset
const emit = defineEmits(['tileMap:create'])
const scene = useScene() const scene = useScene()
const mapStore = useMapStore() const mapStore = useMapStore()
const mapStorage = new MapStorage() const mapStorage = new MapStorage()
const props = defineProps<{ const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
tileMap: Phaser.Tilemaps.Tilemap const tileLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
tileMapLayer: Phaser.Tilemaps.TilemapLayer
}>()
onMounted(async () => { function createTileMap(mapData: any) {
if (!mapStore.mapId) return const mapConfig = new Phaser.Tilemaps.MapData({
width: mapData?.width,
height: mapData?.height,
tileWidth: config.tile_size.width,
tileHeight: config.tile_size.height,
orientation: Phaser.Tilemaps.Orientation.ISOMETRIC,
format: Phaser.Tilemaps.Formats.ARRAY_2D
})
const map = await mapStorage.getById(mapStore.mapId) const newTileMap = new Phaser.Tilemaps.Tilemap(scene, mapConfig)
if (!map) return emit('tileMap:create', newTileMap)
return newTileMap
}
await loadTileTexturesFromMapTileArray(mapStore.mapId, scene) function createTileLayer(currentTileMap: Phaser.Tilemaps.Tilemap, mapData: any) {
const tilesArray = unduplicateArray(FlattenMapArray(mapData?.tiles ?? []))
placeTiles(props.tileMap, props.tileMapLayer, map.tiles) const tilesetImages = tilesArray.map((tile: string, index: number) => {
return currentTileMap.addTilesetImage(tile, tile, config.tile_size.width, config.tile_size.height, 1, 2, index + 1, { x: 0, y: -config.tile_size.height })
})
// Add blank tile
tilesetImages.push(currentTileMap.addTilesetImage('blank_tile', 'blank_tile', config.tile_size.width, config.tile_size.height, 1, 2, 0, { x: 0, y: -config.tile_size.height }))
const layer = currentTileMap.createBlankLayer('tiles', tilesetImages as Tileset[], 0, config.tile_size.height) as Phaser.Tilemaps.TilemapLayer
layer.setDepth(0)
layer.setCullPadding(2, 2)
return layer
}
loadMapTilesIntoScene(mapStore.mapId as UUID, scene)
.then(() => mapStorage.get(mapStore.mapId))
.then((mapData) => {
tileMap.value = createTileMap(mapData)
tileLayer.value = createTileLayer(tileMap.value, mapData)
setLayerTiles(tileMap.value, tileLayer.value, mapData?.tiles)
})
.catch((error) => console.error('Failed to initialize map:', error))
onBeforeUnmount(() => {
if (!tileMap.value) return
tileMap.value.destroyLayer('tiles')
tileMap.value.removeAllLayers()
tileMap.value.destroy()
}) })
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<PlacedMapObject v-for="placedMapObject in items" :tileMap :tileMapLayer :placedMapObject /> <PlacedMapObject v-for="placedMapObject in items" :tilemap="tilemap" :placedMapObject />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -9,11 +9,8 @@ import { MapStorage } from '@/storage/storages'
import { useMapStore } from '@/stores/mapStore' import { useMapStore } from '@/stores/mapStore'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
defineProps<{ defineProps<{
tileMap: Phaser.Tilemaps.Tilemap tilemap: Phaser.Tilemaps.Tilemap
tileMapLayer: TilemapLayer
}>() }>()
const mapStore = useMapStore() const mapStore = useMapStore()
@ -23,7 +20,7 @@ const items = ref<PlacedMapObjectT[]>([])
onMounted(async () => { onMounted(async () => {
if (!mapStore.mapId) return if (!mapStore.mapId) return
const map = await mapStorage.getById(mapStore.mapId) const map = await mapStorage.get(mapStore.mapId)
if (!map) return if (!map) return
items.value = map.placedMapObjects items.value = map.placedMapObjects

View File

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

View File

@ -1,70 +1,43 @@
<template> <template>
<ImageGroup v-bind="groupProps" v-if="mapObject && gameStore.isTextureLoaded(props.placedMapObject.mapObject as string)" /> <Image v-if="gameStore.isTextureLoaded(props.placedMapObject.mapObject.id)" v-bind="imageProps" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapObject, PlacedMapObject } from '@/application/types' import type { PlacedMapObject, TextureData } from '@/application/types'
import ImageGroup from '@/components/game/map/partials/ImageGroup.vue' import { loadTexture } from '@/composables/gameComposable'
import { loadMapObjectTextures, tileToWorldXY } from '@/services/mapService' import { calculateIsometricDepth, tileToWorldX, tileToWorldY } from '@/composables/mapComposable'
import { MapObjectStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useScene } from 'phavuer' import { Image, useScene } from 'phavuer'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted } from 'vue'
import Tilemap = Phaser.Tilemaps.Tilemap
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
const props = defineProps<{ const props = defineProps<{
tilemap: Phaser.Tilemaps.Tilemap
placedMapObject: PlacedMapObject placedMapObject: PlacedMapObject
tileMap: Tilemap
tileMapLayer: TilemapLayer
}>() }>()
const gameStore = useGameStore()
const scene = useScene() const scene = useScene()
const gameStore = useGameStore() const imageProps = computed(() => ({
depth: calculateIsometricDepth(props.placedMapObject.positionX, props.placedMapObject.positionY, props.placedMapObject.mapObject.frameWidth, props.placedMapObject.mapObject.frameHeight),
const mapObject = ref<MapObject>() x: tileToWorldX(props.tilemap, props.placedMapObject.positionX, props.placedMapObject.positionY),
y: tileToWorldY(props.tilemap, props.placedMapObject.positionX, props.placedMapObject.positionY),
const groupProps = computed(() => ({ flipX: props.placedMapObject.isRotated,
...calculateObjectPlacement(props.placedMapObject), texture: props.placedMapObject.mapObject.id,
mapObj: mapObject.value, originY: Number(props.placedMapObject.mapObject.originX),
obj: props.placedMapObject originX: Number(props.placedMapObject.mapObject.originY)
})) }))
async function initialize() { loadTexture(scene, {
if (!props.placedMapObject.mapObject) return key: props.placedMapObject.mapObject.id,
data: '/textures/map_objects/' + props.placedMapObject.mapObject.id + '.png',
/** group: 'map_objects',
* Check if mapObject is an string or object, if its an object we assume its a mapObject and change it to a string updatedAt: props.placedMapObject.mapObject.updatedAt,
* We do this because this component is shared with the map editor, which gets sent the mapObject as an object by the server frameWidth: props.placedMapObject.mapObject.frameWidth,
*/ frameHeight: props.placedMapObject.mapObject.frameHeight
if (typeof props.placedMapObject.mapObject === 'object') { } as TextureData).catch((error) => {
// @ts-ignore console.error('Error loading texture:', error)
props.placedMapObject.mapObject = props.placedMapObject.mapObject.id
}
const mapObjectStorage = new MapObjectStorage()
const _mapObject = await mapObjectStorage.getById(props.placedMapObject.mapObject as string)
if (!_mapObject) return
console.log(_mapObject)
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,
y: position.worldPositionY
}
}
onMounted(async () => {
await initialize()
}) })
onMounted(async () => {})
</script> </script>

View File

@ -6,7 +6,7 @@
<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="mapEditor.toggleActive()">Map editor</button> <button class="btn-cyan py-1.5 px-4 min-w-24" type="button" @click="() => mapEditorStore.toggleActive()">Map editor</button>
</div> </div>
</template> </template>
<template #modalBody> <template #modalBody>
@ -20,12 +20,12 @@
<script setup lang="ts"> <script setup lang="ts">
import AssetManager from '@/components/gameMaster/assetManager/AssetManager.vue' import AssetManager from '@/components/gameMaster/assetManager/AssetManager.vue'
import Modal from '@/components/utilities/Modal.vue' import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useMapEditorStore } from '@/stores/mapEditorStore'
import { ref } from 'vue' import { ref } from 'vue'
const mapEditor = useMapEditorComposable()
const gameStore = useGameStore() const gameStore = useGameStore()
const mapEditorStore = useMapEditorStore()
let toggle = ref('asset-manager') let toggle = ref('asset-manager')
</script> </script>

View File

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

View File

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

View File

@ -40,14 +40,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import type { CharacterGender, CharacterRace, CharacterType, Sprite } from '@/application/types' import type { CharacterGender, CharacterRace, CharacterType, Sprite } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import { socketManager } from '@/managers/SocketManager'
import { CharacterTypeStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore() const assetManagerStore = useAssetManagerStore()
const selectedCharacterType = computed(() => assetManagerStore.selectedCharacterType) const selectedCharacterType = computed(() => assetManagerStore.selectedCharacterType)
@ -73,22 +71,20 @@ if (selectedCharacterType.value) {
characterSpriteId.value = selectedCharacterType.value.sprite?.id characterSpriteId.value = selectedCharacterType.value.sprite?.id
} }
async function removeCharacterType() { function removeCharacterType() {
if (!selectedCharacterType.value) return if (!selectedCharacterType.value) return
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_REMOVE, { id: selectedCharacterType.value.id }, async (response: boolean) => { gameStore.connection?.emit('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
} }
refreshCharacterTypeList()
await downloadCache('character_types', new CharacterTypeStorage())
await refreshCharacterTypeList()
}) })
} }
async function refreshCharacterTypeList(unsetSelectedCharacterType = true) { function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => { gameStore.connection?.emit('gm:characterType:list', {}, (response: CharacterType[]) => {
assetManagerStore.setCharacterTypeList(response) assetManagerStore.setCharacterTypeList(response)
if (unsetSelectedCharacterType) { if (unsetSelectedCharacterType) {
@ -97,7 +93,7 @@ async function refreshCharacterTypeList(unsetSelectedCharacterType = true) {
}) })
} }
async function saveCharacterType() { function saveCharacterType() {
const characterTypeData = { const characterTypeData = {
id: selectedCharacterType.value!.id, id: selectedCharacterType.value!.id,
name: characterName.value, name: characterName.value,
@ -107,14 +103,12 @@ async function saveCharacterType() {
spriteId: characterSpriteId.value spriteId: characterSpriteId.value
} }
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_UPDATE, characterTypeData, async (response: boolean) => { gameStore.connection?.emit('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
} }
refreshCharacterTypeList(false)
await downloadCache('character_types', new CharacterTypeStorage())
await refreshCharacterTypeList(false)
}) })
} }
@ -130,7 +124,7 @@ watch(selectedCharacterType, (characterType: CharacterType | null) => {
onMounted(() => { onMounted(() => {
if (!selectedCharacterType.value) return if (!selectedCharacterType.value) return
socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => { gameStore.connection?.emit('gm:sprite:list', {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response) assetManagerStore.setSpriteList(response)
}) })
}) })

View File

@ -32,9 +32,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import type { CharacterType } from '@/application/types' import type { CharacterType } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -54,13 +52,13 @@ const handleSearch = () => {
} }
const createNewCharacterType = () => { const createNewCharacterType = () => {
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_CREATE, {}, (response: boolean) => { gameStore.connection?.emit('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
} }
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => { gameStore.connection?.emit('gm:characterType:list', {}, (response: CharacterType[]) => {
assetManagerStore.setCharacterTypeList(response) assetManagerStore.setCharacterTypeList(response)
}) })
}) })
@ -94,7 +92,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
socketManager.emit(SocketEvent.GM_CHARACTERTYPE_LIST, {}, (response: CharacterType[]) => { gameStore.connection?.emit('gm:characterType:list', {}, (response: CharacterType[]) => {
assetManagerStore.setCharacterTypeList(response) assetManagerStore.setCharacterTypeList(response)
}) })
}) })

View File

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

View File

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

View File

@ -1,30 +1,8 @@
<template> <template>
<div class="h-full overflow-auto"> <div class="h-full overflow-auto">
<div class="relative p-2.5 flex flex-col items-center justify-center h-72 rounded-md default-border bg-gray"> <div class="relative p-2.5 flex flex-col items-center justify-center h-72 rounded-md default-border bg-gray">
<div class="grid grid-cols-[160px_auto_max-content] gap-12"> <img class="max-h-56" :src="`${config.server_endpoint}/textures/map_objects/${selectedMapObject?.id}.png`" :alt="'Object ' + selectedMapObject?.id" />
<div>
<input type="checkbox" checked v-model="showOrigin" /><label>Show Origin</label>
<br />
<input type="checkbox" checked v-model="showPartitionOverlay" /><label>Show Partitions</label>
</div> </div>
<div class="relative w-fit h-fit">
<img class="max-h-56" :src="`${config.server_endpoint}/textures/map_objects/${selectedMapObject?.id}.png`" :alt="'Object ' + selectedMapObject?.id" ref="imageRef" />
<svg ref="svg" class="absolute top-0 left-0 w-full h-full inline-block pointer-events-none">
<circle v-if="showOrigin && svg" r="4" :cx="mapObjectOriginX * width" :cy="mapObjectOriginY * height" stroke="white" stroke-width="2" />
<rect v-if="showPartitionOverlay && svg" v-for="(offset, index) in mapObjectDepthOffsets" style="opacity: 0.5" stroke="red" :x="index * (width / mapObjectDepthOffsets.length)" :width="width / mapObjectDepthOffsets.length" :y="0" :height="height" />
</svg>
</div>
<div>
<button class="btn-cyan px-4 py-1.5 min-w-24" @click="mapObjectDepthOffsets.push(0)">Add Partition</button>
<p>Depth Offset</p>
<div class="text-white grid grid-cols-[120px_80px_auto] items-baseline gap-2" v-for="(offset, index) in mapObjectDepthOffsets">
<input class="input-field max-h-4 mt-2" type="number" :value="offset" @change="setPartitionDepth($event, index)" />
<button @click="mapObjectDepthOffsets.splice(index, 1)">Remove</button>
</div>
</div>
</div>
</div>
<div class="mt-5 block"> <div class="mt-5 block">
<form class="flex gap-2.5 flex-wrap" @submit.prevent="saveObject"> <form class="flex gap-2.5 flex-wrap" @submit.prevent="saveObject">
<div class="form-field-full"> <div class="form-field-full">
@ -43,6 +21,13 @@
<label for="tags">Tags</label> <label for="tags">Tags</label>
<ChipsInput v-model="mapObjectTags" @update:modelValue="mapObjectTags = $event" /> <ChipsInput v-model="mapObjectTags" @update:modelValue="mapObjectTags = $event" />
</div> </div>
<div class="form-field-full">
<label for="is-animated">Is animated</label>
<select v-model="mapObjectIsAnimated" class="input-field" name="is-animated">
<option :value="false">No</option>
<option :value="true">Yes</option>
</select>
</div>
<div class="form-field-full"> <div class="form-field-full">
<label for="frame-speed">Frame rate</label> <label for="frame-speed">Frame rate</label>
<input v-model="mapObjectFrameRate" class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame rate" /> <input v-model="mapObjectFrameRate" class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame rate" />
@ -66,34 +51,27 @@
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import type { MapObject } from '@/application/types' import type { MapObject } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import ChipsInput from '@/components/forms/ChipsInput.vue' import ChipsInput from '@/components/forms/ChipsInput.vue'
import { socketManager } from '@/managers/SocketManager'
import { MapObjectStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useElementSize } from '@vueuse/core' import { useGameStore } from '@/stores/gameStore'
import { Rectangle } from 'phavuer' import { useMapEditorStore } from '@/stores/mapEditorStore'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore() const assetManagerStore = useAssetManagerStore()
const mapEditorStore = useMapEditorStore()
const selectedMapObject = computed(() => assetManagerStore.selectedMapObject) const selectedMapObject = computed(() => assetManagerStore.selectedMapObject)
const svg = useTemplateRef('svg')
const { width, height } = useElementSize(svg)
const mapObjectName = ref('') const mapObjectName = ref('')
const mapObjectTags = ref<string[]>([]) const mapObjectTags = ref<string[]>([])
const mapObjectDepthOffsets = ref<number[]>([])
const mapObjectOriginX = ref(0) const mapObjectOriginX = ref(0)
const mapObjectOriginY = ref(0) const mapObjectOriginY = ref(0)
const mapObjectIsAnimated = ref(false)
const mapObjectFrameRate = ref(0) const mapObjectFrameRate = ref(0)
const mapObjectFrameWidth = ref(0) const mapObjectFrameWidth = ref(0)
const mapObjectFrameHeight = ref(0) const mapObjectFrameHeight = ref(0)
const imageRef = ref<HTMLImageElement | null>(null)
const showOrigin = ref(true)
const showPartitionOverlay = ref(true)
if (!selectedMapObject.value) { if (!selectedMapObject.value) {
console.error('No map mapObject selected') console.error('No map mapObject selected')
@ -102,65 +80,63 @@ if (!selectedMapObject.value) {
if (selectedMapObject.value) { if (selectedMapObject.value) {
mapObjectName.value = selectedMapObject.value.name mapObjectName.value = selectedMapObject.value.name
mapObjectTags.value = selectedMapObject.value.tags mapObjectTags.value = selectedMapObject.value.tags
mapObjectDepthOffsets.value = selectedMapObject.value.depthOffsets
mapObjectOriginX.value = selectedMapObject.value.originX mapObjectOriginX.value = selectedMapObject.value.originX
mapObjectOriginY.value = selectedMapObject.value.originY mapObjectOriginY.value = selectedMapObject.value.originY
mapObjectIsAnimated.value = selectedMapObject.value.isAnimated
mapObjectFrameRate.value = selectedMapObject.value.frameRate mapObjectFrameRate.value = selectedMapObject.value.frameRate
mapObjectFrameWidth.value = selectedMapObject.value.frameWidth mapObjectFrameWidth.value = selectedMapObject.value.frameWidth
mapObjectFrameHeight.value = selectedMapObject.value.frameHeight mapObjectFrameHeight.value = selectedMapObject.value.frameHeight
} }
const setPartitionDepth = (event: any, idx: number) => (mapObjectDepthOffsets.value[idx] = Number.parseInt(event.target.value)) function removeObject() {
gameStore.connection?.emit('gm:mapObject:remove', { mapObject: selectedMapObject.value?.id }, (response: boolean) => {
async function removeObject() {
if (!selectedMapObject.value) return
socketManager.emit(SocketEvent.GM_MAPOBJECT_REMOVE, { mapObjectId: selectedMapObject.value.id }, async (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to remove mapObject') console.error('Failed to remove mapObject')
return return
} }
refreshObjectList()
await downloadCache('map_objects', new MapObjectStorage())
await refreshObjectList()
}) })
} }
async function refreshObjectList(unsetSelectedMapObject = true) { function refreshObjectList(unsetSelectedMapObject = true) {
socketManager.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => { gameStore.connection?.emit('gm:mapObject:list', {}, (response: MapObject[]) => {
assetManagerStore.setMapObjectList(response) assetManagerStore.setMapObjectList(response)
if (unsetSelectedMapObject) { if (unsetSelectedMapObject) {
assetManagerStore.setSelectedMapObject(null) assetManagerStore.setSelectedMapObject(null)
} }
if (mapEditorStore.active) {
mapEditorStore.setMapObjectList(response)
}
}) })
} }
async function saveObject() { function saveObject() {
if (!selectedMapObject.value) { if (!selectedMapObject.value) {
console.error('No mapObject selected') console.error('No mapObject selected')
return return
} }
socketManager.emit(
SocketEvent.GM_MAPOBJECT_UPDATE, gameStore.connection?.emit(
'gm:mapObject:update',
{ {
id: selectedMapObject.value.id, id: selectedMapObject.value.id,
name: mapObjectName.value, name: mapObjectName.value,
tags: mapObjectTags.value, tags: mapObjectTags.value,
depthOffsets: mapObjectDepthOffsets.value,
originX: mapObjectOriginX.value, originX: mapObjectOriginX.value,
originY: mapObjectOriginY.value, originY: mapObjectOriginY.value,
isAnimated: mapObjectIsAnimated.value,
frameRate: mapObjectFrameRate.value, frameRate: mapObjectFrameRate.value,
frameWidth: mapObjectFrameWidth.value, frameWidth: mapObjectFrameWidth.value,
frameHeight: mapObjectFrameHeight.value frameHeight: mapObjectFrameHeight.value
}, },
async (response: boolean) => { (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to save mapObject') console.error('Failed to save mapObject')
return return
} }
refreshObjectList(false)
await downloadCache('map_objects', new MapObjectStorage())
await refreshObjectList(false)
} }
) )
} }
@ -169,9 +145,9 @@ watch(selectedMapObject, (mapObject: MapObject | null) => {
if (!mapObject) return if (!mapObject) return
mapObjectName.value = mapObject.name mapObjectName.value = mapObject.name
mapObjectTags.value = mapObject.tags mapObjectTags.value = mapObject.tags
mapObjectDepthOffsets.value = mapObject.depthOffsets
mapObjectOriginX.value = mapObject.originX mapObjectOriginX.value = mapObject.originX
mapObjectOriginY.value = mapObject.originY mapObjectOriginY.value = mapObject.originY
mapObjectIsAnimated.value = mapObject.isAnimated
mapObjectFrameRate.value = mapObject.frameRate mapObjectFrameRate.value = mapObject.frameRate
mapObjectFrameWidth.value = mapObject.frameWidth mapObjectFrameWidth.value = mapObject.frameWidth
mapObjectFrameHeight.value = mapObject.frameHeight mapObjectFrameHeight.value = mapObject.frameHeight
@ -181,37 +157,7 @@ onMounted(() => {
if (!selectedMapObject.value) return if (!selectedMapObject.value) return
}) })
// function startDragging(index: number, event: MouseEvent) {
// isDragging.value = true
// draggedPointIndex.value = index
//
// const moveHandler = (e: MouseEvent) => {
// if (!isDragging.value || !imageRef.value) return
// const rect = imageRef.value.getBoundingClientRect()
// mapObjectPivotPoints.value[draggedPointIndex.value] = {
// x: e.clientX - rect.left,
// y: e.clientY - rect.top
// }
// }
//
// const upHandler = () => {
// isDragging.value = false
// draggedPointIndex.value = -1
// window.removeEventListener('mousemove', moveHandler)
// window.removeEventListener('mouseup', upHandler)
// }
//
// window.addEventListener('mousemove', moveHandler)
// window.addEventListener('mouseup', upHandler)
// }
onBeforeUnmount(() => { onBeforeUnmount(() => {
assetManagerStore.setSelectedMapObject(null) assetManagerStore.setSelectedMapObject(null)
}) })
</script> </script>
<style scoped>
.pointer-events-none {
pointer-events: none;
}
</style>

View File

@ -29,9 +29,7 @@
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import type { MapObject } from '@/application/types' import type { MapObject } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -49,13 +47,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
socketManager.emit(SocketEvent.GM_MAPOBJECT_UPLOAD, files, (response: boolean) => { gameStore.connection?.emit('gm:mapObject:upload', files, (response: boolean) => {
if (!response) { if (!response) {
if (config.environment === 'development') console.error('Failed to upload map object') if (config.development) console.error('Failed to upload object')
return return
} }
socketManager.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => { gameStore.connection?.emit('gm:mapObject:list', {}, (response: MapObject[]) => {
assetManagerStore.setMapObjectList(response) assetManagerStore.setMapObjectList(response)
}) })
}) })
@ -94,7 +92,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
socketManager.emit(SocketEvent.GM_MAPOBJECT_LIST, {}, (response: MapObject[]) => { gameStore.connection?.emit('gm:mapObject:list', {}, (response: MapObject[]) => {
assetManagerStore.setMapObjectList(response) assetManagerStore.setMapObjectList(response)
}) })
}) })

View File

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

View File

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

View File

@ -1,366 +0,0 @@
<template>
<Modal :is-modal-open="isModalOpen" :modal-width="700" :modal-height="330" :can-full-screen="true" bg-style="none" @modal:close="closeModal">
<template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">Sprite editor</h3>
</template>
<template #modalBody>
<div class="m-4 flex gap-4 h-full">
<!-- Settings -->
<div class="w-80 h-full flex flex-col overflow-y-auto">
<div class="flex flex-col gap-4">
<div class="flex flex-col">
<label class="block mb-1 text-white text-sm">Frame Rate: {{ frameRate }} FPS</label>
<div class="text-xs font-default text-gray-400 mb-1">Duration: {{ totalDuration }}s</div>
<input type="range" v-model.number="localFrameRate" min="0" max="60" step="1" class="w-full accent-cyan-500" @input="updateFrameRate" />
</div>
<div class="flex flex-col">
<label class="block mb-1 text-white text-sm">Frame: {{ currentFrame + 1 }} of {{ sprites.length }}</label>
<input type="range" v-model.number="currentFrame" :min="0" :max="sprites.length - 1" step="1" class="w-full accent-cyan-500" @input="stopAnimation" />
</div>
<div class="flex flex-col">
<label class="block mb-1 text-white text-sm">Zoom: {{ zoomLevel }}%</label>
<input type="range" v-model.number="zoomLevel" min="10" max="600" step="10" class="w-full accent-cyan-500" />
</div>
</div>
<div class="mt-6 space-y-2">
<button @click="toggleAnimation" class="px-3 py-1 bg-cyan-600 hover:bg-cyan-700 text-white rounded transition-colors w-full">
{{ isAnimating ? 'Pause' : 'Play' }}
</button>
<button @click="toggleReferenceSprites" class="px-3 py-1 bg-cyan-600 hover:bg-cyan-700 text-white rounded transition-colors w-full">
{{ showReferenceSprites ? 'Hide References' : 'Show References' }}
</button>
</div>
<div class="mt-6">
<form class="flex gap-2.5 flex-wrap" @submit.prevent="">
<div class="relative flex py-5 items-center">
<div class="flex-grow border-t border-gray-400"></div>
<span class="flex-shrink mx-4 text-gray-400">Sprite action</span>
<div class="flex-grow border-solid border-gray-200"></div>
</div>
<div class="form-field-full">
<label for="action">Name</label>
<input class="input-field" type="text" name="action" placeholder="Action" />
</div>
<div class="form-field-half">
<label for="origin-x">Origin X</label>
<input class="input-field" type="number" step="any" name="origin-x" placeholder="Origin X" />
</div>
<div class="form-field-half">
<label for="origin-y">Origin Y</label>
<input class="input-field" type="number" step="any" name="origin-y" placeholder="Origin Y" />
</div>
<div class="relative flex py-5 items-center">
<div class="flex-grow border-t border-gray-400"></div>
<span class="flex-shrink mx-4 text-gray-400">Sprite action image</span>
<div class="flex-grow border-t border-gray-400"></div>
</div>
<div class="form-field-half">
<label for="offset-x">Offset X</label>
<input class="input-field" type="number" step="1" v-model.number="offsetXModel" :disabled="isAnimating" />
</div>
<div class="form-field-half">
<label for="offset-y">Offset Y</label>
<input class="input-field" type="number" step="1" v-model.number="offsetYModel" :disabled="isAnimating" />
</div>
<div class="form-field-full">
<label for="frame-speed">Frame rate</label>
<input class="input-field" type="number" step="any" name="frame-speed" placeholder="Frame rate" />
</div>
</form>
</div>
</div>
<!-- Sprite thumbnails -->
<div class="flex-1 flex flex-col h-full">
<div class="bg-gray-800 border-solid border-white/10 rounded flex-grow mb-2 relative overflow-hidden" @mousedown="startDrag" @mousemove="onDrag" @mouseup="stopDrag" @mouseleave="stopDrag">
<!-- Background reference sprites (semi-transparent) -->
<img
v-for="(sprite, index) in spritesWithTempOffset"
:key="`bg-${index}`"
:src="sprite.url"
alt="Reference sprite"
v-show="index !== currentFrame && showReferenceSprites"
:style="{
position: 'absolute',
left: `${(sprite.offset?.x || 0) * (zoomLevel / 100)}px`,
bottom: `${(sprite.offset?.y || 0) * (zoomLevel / 100)}px`,
opacity: 0.3,
transform: `scale(${zoomLevel / 100})`,
transformOrigin: 'bottom left',
pointerEvents: 'none'
}"
/>
<!-- Current sprite (draggable) -->
<img
v-for="(sprite, index) in spritesWithTempOffset"
:key="index"
:src="sprite.url"
alt="Sprite"
:class="{ 'cursor-move': currentFrame === index }"
:style="{
position: 'absolute',
left: `${(sprite.offset?.x || 0) * (zoomLevel / 100)}px`,
bottom: `${(sprite.offset?.y || 0) * (zoomLevel / 100)}px`,
display: currentFrame === index ? 'block' : 'none',
transform: `scale(${zoomLevel / 100})`,
transformOrigin: 'bottom left',
userSelect: 'none',
pointerEvents: currentFrame === index ? 'auto' : 'none'
}"
@dragstart.prevent
/>
</div>
<div class="bg-gray-800 p-2 overflow-x-auto border-solid border-white/10 rounded mb-8 h-24 min-h-16">
<div class="flex gap-2">
<div
v-for="(sprite, index) in sprites"
:key="`thumb-${index}`"
class="relative cursor-pointer border-solid transition-all duration-200 rounded flex-shrink-0 p-3 px-12"
:class="currentFrame === index ? 'border-cyan-600 bg-cyan-500/10' : 'border-transparent hover:border-white/30'"
@click="selectFrame(index)"
>
<img :src="sprite.url" alt="Sprite thumbnail" class="h-16 w-auto object-contain rounded" />
<div class="absolute top-0 right-0 bg-gray-400 text-white text-xs font-default px-1 rounded-bl">
{{ index + 1 }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import type { Sprite, SpriteImage } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
sprite: Sprite
sprites: SpriteImage[]
frameRate: number
isModalOpen?: boolean
tempOffsetIndex?: number
tempOffset?: { x: number; y: number }
}>()
const emit = defineEmits<{
(e: 'update:frameRate', value: number): void
(e: 'update:isModalOpen', value: boolean): void
(e: 'update:tempOffset', index: number, offset: { x: number; y: number }): void
}>()
const currentFrame = ref(0)
const localFrameRate = ref(props.frameRate)
const zoomLevel = ref(100)
const isAnimating = ref(false)
const isDragging = ref(false)
const startDragPos = ref({ x: 0, y: 0 })
const currentOffset = ref({ x: 0, y: 0 })
let animationInterval: number | null = null
const totalDuration = computed(() => {
if (props.frameRate <= 0) return 0
return (props.sprites.length / props.frameRate).toFixed(2)
})
const spritesWithTempOffset = computed(() => {
return props.sprites.map((sprite, index) => {
if (index === props.tempOffsetIndex && props.tempOffset) {
return { ...sprite, offset: props.tempOffset }
}
return sprite
})
})
const currentSprite = computed(() => {
if (currentFrame.value >= 0 && currentFrame.value < spritesWithTempOffset.value.length) {
return spritesWithTempOffset.value[currentFrame.value]
}
return null
})
// Create computed properties with getters and setters for two-way binding
const offsetXModel = computed({
get: () => currentSprite.value?.offset?.x || 0,
set: (value) => {
if (isAnimating.value) return
const newOffset = {
x: value,
y: currentSprite.value?.offset?.y || 0
}
emit('update:tempOffset', currentFrame.value, newOffset)
}
})
const offsetYModel = computed({
get: () => currentSprite.value?.offset?.y || 0,
set: (value) => {
if (isAnimating.value) return
const newOffset = {
x: currentSprite.value?.offset?.x || 0,
y: value
}
emit('update:tempOffset', currentFrame.value, newOffset)
}
})
// Toggle for showing reference sprites
const showReferenceSprites = ref(true)
function updateAnimation() {
stopAnimation()
if (props.frameRate <= 0 || props.sprites.length === 0) {
currentFrame.value = 0
return
}
if (isAnimating.value) {
animationInterval = window.setInterval(() => {
currentFrame.value = (currentFrame.value + 1) % props.sprites.length
}, 1000 / props.frameRate)
}
}
function toggleAnimation() {
isAnimating.value = !isAnimating.value
if (isAnimating.value) {
updateAnimation()
} else {
stopAnimation()
}
}
function stopAnimation() {
if (animationInterval) {
clearInterval(animationInterval)
animationInterval = null
}
}
function selectFrame(index: number) {
currentFrame.value = index
stopAnimation()
isAnimating.value = false
}
function updateFrameRate() {
emit('update:frameRate', localFrameRate.value)
}
function closeModal() {
emit('update:isModalOpen', false)
}
function startDrag(event: MouseEvent) {
if (isAnimating.value) return
const previewContainer = event.currentTarget as HTMLElement
const rect = previewContainer.getBoundingClientRect()
// Store initial mouse position
startDragPos.value = {
x: event.clientX,
y: event.clientY
}
// Store current offset
if (currentSprite.value && currentSprite.value.offset) {
currentOffset.value = {
x: currentSprite.value.offset.x,
y: currentSprite.value.offset.y
}
} else {
currentOffset.value = { x: 0, y: 0 }
}
isDragging.value = true
}
function onDrag(event: MouseEvent) {
if (!isDragging.value) return
// Calculate the difference from the start position
const deltaX = event.clientX - startDragPos.value.x
const deltaY = startDragPos.value.y - event.clientY // Inverted for bottom positioning
// Apply the zoom factor to the delta
// This ensures that the movement in screen pixels is converted to the correct
// number of pixels at the sprite's natural size, regardless of zoom level
const zoomFactor = 100 / zoomLevel.value
const scaledDeltaX = deltaX * zoomFactor
const scaledDeltaY = deltaY * zoomFactor
// Calculate new offset
// These offsets are in the sprite's natural coordinate space (as if zoom was 100%)
const newOffset = {
x: currentOffset.value.x + scaledDeltaX,
y: currentOffset.value.y + scaledDeltaY
}
// Emit the new offset
emit('update:tempOffset', currentFrame.value, newOffset)
}
function stopDrag() {
if (isDragging.value && currentSprite.value?.offset) {
// Ensure the final offset is applied when dragging stops
emit('update:tempOffset', currentFrame.value, {
x: currentSprite.value.offset.x,
y: currentSprite.value.offset.y
})
}
isDragging.value = false
}
function toggleReferenceSprites() {
showReferenceSprites.value = !showReferenceSprites.value
}
function updateOffset(event: Event, axis: 'x' | 'y') {
if (isAnimating.value) return
const input = event.target as HTMLInputElement
const value = parseInt(input.value) || 0
if (currentSprite.value && currentSprite.value.offset) {
const newOffset = { ...currentSprite.value.offset }
newOffset[axis] = value
emit('update:tempOffset', currentFrame.value, newOffset)
}
}
watch(
() => props.frameRate,
(newValue) => {
localFrameRate.value = newValue
updateAnimation()
},
{ immediate: true }
)
watch(() => props.sprites, updateAnimation, { immediate: true })
watch(
() => isAnimating.value,
(newValue) => {
if (newValue) {
updateAnimation()
} else {
stopAnimation()
}
}
)
onMounted(() => {
isAnimating.value = props.frameRate > 0
if (isAnimating.value) {
updateAnimation()
}
})
onUnmounted(() => {
stopAnimation()
})
</script>

View File

@ -0,0 +1,104 @@
<template>
<div class="flex flex-wrap gap-3">
<div v-for="(image, index) in modelValue" :key="index" class="h-20 w-20 p-4 bg-gray-300 bg-opacity-50 rounded text-center relative group cursor-move" draggable="true" @dragstart="dragStart($event, index)" @dragover.prevent @dragenter.prevent @drop="drop($event, index)">
<img :src="image" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" />
<div class="absolute top-1 left-1 flex-row space-y-1">
<button @click.stop="deleteImage(index)" class="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity" aria-label="Delete image">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button 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 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
</div>
<div class="h-20 w-20 p-4 bg-gray-200 bg-opacity-50 rounded justify-center items-center flex hover:cursor-pointer" @click="triggerFileInput" @drop.prevent="onDrop" @dragover.prevent>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 invert" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
</div>
<input type="file" ref="fileInput" @change="onFileChange" multiple accept="image/png" class="hidden" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
modelValue: string[]
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => []
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const draggedIndex = ref<number | null>(null)
const triggerFileInput = () => {
fileInput.value?.click()
}
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files) {
handleFiles(target.files)
}
}
const onDrop = (event: DragEvent) => {
if (event.dataTransfer?.files) {
handleFiles(event.dataTransfer.files)
}
}
const handleFiles = (files: FileList) => {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (e) => {
if (typeof e.target?.result === 'string') {
updateImages([...props.modelValue, e.target.result])
}
}
reader.readAsDataURL(file)
}
})
}
const updateImages = (newImages: string[]) => {
emit('update:modelValue', newImages)
}
const deleteImage = (index: number) => {
const newImages = [...props.modelValue]
newImages.splice(index, 1)
updateImages(newImages)
}
const dragStart = (event: DragEvent, index: number) => {
draggedIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.dropEffect = 'move'
}
}
const drop = (event: DragEvent, dropIndex: number) => {
event.preventDefault()
if (draggedIndex.value !== null && draggedIndex.value !== dropIndex) {
const newImages = [...props.modelValue]
const [reorderedItem] = newImages.splice(draggedIndex.value, 1)
newImages.splice(dropIndex, 0, reorderedItem)
updateImages(newImages)
}
draggedIndex.value = null
}
</script>

View File

@ -24,16 +24,16 @@
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import type { Tile } from '@/application/types' import type { Tile } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import ChipsInput from '@/components/forms/ChipsInput.vue' import ChipsInput from '@/components/forms/ChipsInput.vue'
import { socketManager } from '@/managers/SocketManager'
import { TileStorage } from '@/storage/storages'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useGameStore } from '@/stores/gameStore'
import { useMapEditorStore } from '@/stores/mapEditorStore'
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
const gameStore = useGameStore()
const assetManagerStore = useAssetManagerStore() const assetManagerStore = useAssetManagerStore()
const mapEditorStore = useMapEditorStore()
const selectedTile = computed(() => assetManagerStore.selectedTile) const selectedTile = computed(() => assetManagerStore.selectedTile)
@ -55,49 +55,49 @@ watch(selectedTile, (tile: Tile | null) => {
tileTags.value = tile.tags tileTags.value = tile.tags
}) })
async function deleteTile() { function deleteTile() {
socketManager.emit(SocketEvent.GM_TILE_DELETE, { id: selectedTile.value?.id }, async (response: boolean) => { gameStore.connection?.emit('gm:tile:delete', { id: selectedTile.value?.id }, (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to delete tile') console.error('Failed to delete tile')
return return
} }
refreshTileList()
await downloadCache('tiles', new TileStorage())
await refreshTileList()
}) })
} }
async function refreshTileList(unsetSelectedTile = true) { function refreshTileList(unsetSelectedTile = true) {
socketManager.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => { gameStore.connection?.emit('gm:tile:list', {}, (response: Tile[]) => {
assetManagerStore.setTileList(response) assetManagerStore.setTileList(response)
if (unsetSelectedTile) { if (unsetSelectedTile) {
assetManagerStore.setSelectedTile(null) assetManagerStore.setSelectedTile(null)
} }
if (mapEditorStore.active) {
mapEditorStore.setTileList(response)
}
}) })
} }
async function saveTile() { function saveTile() {
if (!selectedTile.value) { if (!selectedTile.value) {
console.error('No tile selected') console.error('No tile selected')
return return
} }
socketManager.emit( gameStore.connection?.emit(
'gm:tile:update', 'gm:tile:update',
{ {
id: selectedTile.value.id, id: selectedTile.value.id,
name: tileName.value, name: tileName.value,
tags: tileTags.value tags: tileTags.value
}, },
async (response: boolean) => { (response: boolean) => {
if (!response) { if (!response) {
console.error('Failed to save tile') console.error('Failed to save tile')
return return
} }
refreshTileList(false)
await downloadCache('tiles', new TileStorage())
await refreshTileList(false)
} }
) )
} }

View File

@ -29,9 +29,7 @@
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config' import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import type { Tile } from '@/application/types' import type { Tile } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore' import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core' import { useVirtualList } from '@vueuse/core'
@ -49,13 +47,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
socketManager.emit(SocketEvent.GM_TILE_UPLOAD, files, (response: boolean) => { gameStore.connection?.emit('gm:tile:upload', files, (response: boolean) => {
if (!response) { if (!response) {
if (config.environment === 'development') console.error('Failed to upload tile') if (config.development) console.error('Failed to upload tile')
return return
} }
socketManager.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => { gameStore.connection?.emit('gm:tile:list', {}, (response: Tile[]) => {
assetManagerStore.setTileList(response) assetManagerStore.setTileList(response)
}) })
}) })
@ -94,7 +92,7 @@ function toTop() {
} }
onMounted(() => { onMounted(() => {
socketManager.emit(SocketEvent.GM_TILE_LIST, {}, (response: Tile[]) => { gameStore.connection?.emit('gm:tile:list', {}, (response: Tile[]) => {
assetManagerStore.setTileList(response) assetManagerStore.setTileList(response)
}) })
}) })

View File

@ -1,245 +0,0 @@
<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, createTileLayer, createTileMap, placeTiles } from '@/services/mapService'
import { TileStorage } from '@/storage/storages'
import { 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 = []
mapEditor.currentMap.value.mapEventTiles = []
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()
})
setInterval(() => {
scene.children.queueDepthSort()
}, 0.2)
onBeforeUnmount(() => {
removeEventListener('keydown', handleKeyDown)
})
</script>

View File

@ -0,0 +1,72 @@
<template>
<MapTiles @tileMap:create="tileMap = $event" />
<PlacedMapObjects v-if="tileMap" :tilemap="tileMap as Phaser.Tilemaps.Tilemap" />
<MapEventTiles v-if="tileMap" :tilemap="tileMap as Phaser.Tilemaps.Tilemap" />
<Toolbar @save="save" @clear="clear" />
<MapList />
<TileList />
<ObjectList />
<MapSettings />
<TeleportModal />
</template>
<script setup lang="ts">
import { type Map } 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 MapList from '@/components/gameMaster/mapEditor/partials/MapList.vue'
import ObjectList from '@/components/gameMaster/mapEditor/partials/MapObjectList.vue'
import MapSettings from '@/components/gameMaster/mapEditor/partials/MapSettings.vue'
import TeleportModal from '@/components/gameMaster/mapEditor/partials/TeleportModal.vue'
import TileList from '@/components/gameMaster/mapEditor/partials/TileList.vue'
import Toolbar from '@/components/gameMaster/mapEditor/partials/Toolbar.vue'
import { useGameStore } from '@/stores/gameStore'
import { useMapEditorStore } from '@/stores/mapEditorStore'
import { onUnmounted, shallowRef } from 'vue'
const gameStore = useGameStore()
const mapEditorStore = useMapEditorStore()
const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
function clear() {
if (!mapEditorStore.map) return
// Clear objects, event tiles and tiles
mapEditorStore.map.placedMapObjects = []
mapEditorStore.map.mapEventTiles = []
mapEditorStore.triggerClearTiles()
}
function save() {
if (!mapEditorStore.map) return
const data = {
mapId: mapEditorStore.map.id,
name: mapEditorStore.mapSettings.name,
width: mapEditorStore.mapSettings.width,
height: mapEditorStore.mapSettings.height,
tiles: mapEditorStore.map.tiles,
pvp: mapEditorStore.map.pvp,
mapEffects: mapEditorStore.map.mapEffects?.map(({ id, effect, strength }) => ({ id, effect, strength })) ?? [],
mapEventTiles: mapEditorStore.map.mapEventTiles?.map(({ id, type, positionX, positionY, teleport }) => ({ id, type, positionX, positionY, teleport })) ?? [],
placedMapObjects: mapEditorStore.map.placedMapObjects?.map(({ id, mapObject, depth, isRotated, positionX, positionY }) => ({ id, mapObject, depth, isRotated, positionX, positionY })) ?? []
}
if (mapEditorStore.isSettingsModalShown) {
mapEditorStore.toggleSettingsModal()
}
gameStore.connection?.emit('gm:map:update', data, (response: Map) => {
mapEditorStore.setMap(response)
})
}
onUnmounted(() => {
mapEditorStore.reset()
})
</script>

View File

@ -1,156 +1,118 @@
<template> <template>
<Image v-for="tile in mapEditor.currentMap.value?.mapEventTiles" v-bind="getImageProps(tile)" /> <Image v-for="tile in mapEditorStore.map?.mapEventTiles" v-bind="getImageProps(tile)" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MapEventTileType, type MapEventTile, type Map as MapT, type UUID } from '@/application/types' import { MapEventTileType, type MapEventTile } from '@/application/types'
import { uuidv4 } from '@/application/utilities' import { uuidv4 } from '@/application/utilities'
import { type EditorCommand } from '@/components/gameMaster/mapEditor/Map.vue' import { getTile, tileToWorldX, tileToWorldY } from '@/composables/mapComposable'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { useMapEditorStore } from '@/stores/mapEditorStore'
import { cloneArray, getTile, tileToWorldX, tileToWorldY } from '@/services/mapService' import { Image, useScene } from 'phavuer'
import { Image } from 'phavuer' import { onMounted, onUnmounted } from 'vue'
const mapEditor = useMapEditorComposable() const scene = useScene()
const mapEditorStore = useMapEditorStore()
defineExpose({ handlePointer, finalizeCommand, clearTiles })
const emit = defineEmits(['createCommand'])
const props = defineProps<{ const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap 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 | null, operation: 'draw' | 'erase' | 'clear' = 'draw') {
if (!tile) return
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) { function getImageProps(tile: MapEventTile) {
return { return {
x: tileToWorldX(props.tileMap, tile.positionX, tile.positionY), x: tileToWorldX(props.tilemap, tile.positionX, tile.positionY),
y: tileToWorldY(props.tileMap, tile.positionX, tile.positionY), y: tileToWorldY(props.tilemap, tile.positionX, tile.positionY),
texture: tile.type, texture: tile.type,
depth: 999 depth: 999
} }
} }
function pencil(pointer: Phaser.Input.Pointer, map: MapT) { function pencil(pointer: Phaser.Input.Pointer) {
// Check if map is set
if (!mapEditorStore.map) return
// Check if tool is pencil
if (mapEditorStore.tool !== 'pencil') return
// Check if draw mode is blocking tile or teleport
if (mapEditorStore.drawMode !== 'blocking tile' && mapEditorStore.drawMode !== 'teleport') 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 there is a tile // Check if there is a tile
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY) const tile = getTile(props.tilemap, pointer.worldX, pointer.worldY)
if (!tile) return if (!tile) return
// Check if event tile already exists on position // Check if event tile already exists on position
const existingEventTile = map.mapEventTiles.find((eventTile) => eventTile.positionX === tile.x && eventTile.positionY === tile.y) const existingEventTile = mapEditorStore.map.mapEventTiles.find((eventTile) => eventTile.positionX === tile.x && eventTile.positionY === tile.y)
if (existingEventTile) return if (existingEventTile) return
// If teleport, check if there is a selected map // If teleport, check if there is a selected map
if (mapEditor.drawMode.value === 'teleport' && !mapEditor.teleportSettings.value.toMap) return if (mapEditorStore.drawMode === 'teleport' && !mapEditorStore.teleportSettings.toMap) return
const newEventTile = { const newEventTile = {
id: uuidv4() as UUID, id: uuidv4(),
type: mapEditor.drawMode.value === 'blocking tile' ? MapEventTileType.BLOCK : MapEventTileType.TELEPORT, mapId: mapEditorStore.map.id,
map: mapEditorStore.map,
type: mapEditorStore.drawMode === 'blocking tile' ? MapEventTileType.BLOCK : MapEventTileType.TELEPORT,
positionX: tile.x, positionX: tile.x,
positionY: tile.y, positionY: tile.y,
teleport: teleport:
mapEditor.drawMode.value === 'teleport' mapEditorStore.drawMode === 'teleport'
? { ? {
toMap: mapEditor.teleportSettings.value.toMap, toMap: mapEditorStore.teleportSettings.toMap,
toPositionX: mapEditor.teleportSettings.value.toPositionX, toPositionX: mapEditorStore.teleportSettings.toPositionX,
toPositionY: mapEditor.teleportSettings.value.toPositionY, toPositionY: mapEditorStore.teleportSettings.toPositionY,
toRotation: mapEditor.teleportSettings.value.toRotation toRotation: mapEditorStore.teleportSettings.toRotation
} }
: undefined : undefined
} }
createCommandUpdate(newEventTile as MapEventTile, 'draw') mapEditorStore.map.mapEventTiles = mapEditorStore.map.mapEventTiles.concat(newEventTile as MapEventTile)
map.mapEventTiles.push(newEventTile as MapEventTile)
} }
function erase(pointer: Phaser.Input.Pointer, map: MapT) { function eraser(pointer: Phaser.Input.Pointer) {
// Check if map is set
if (!mapEditorStore.map) return
// Check if tool is pencil
if (mapEditorStore.tool !== 'eraser') return
// Check if draw mode is blocking tile or teleport
if (mapEditorStore.eraserMode !== 'blocking tile' && mapEditorStore.eraserMode !== 'teleport') 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 there is a tile // Check if there is a tile
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY) const tile = getTile(props.tilemap, pointer.worldX, pointer.worldY)
if (!tile) return if (!tile) return
// Check if event tile already exists on position // Check if event tile already exists on position
const existingEventTile = map.mapEventTiles.find((eventTile) => eventTile.positionX === tile.x && eventTile.positionY === tile.y) const existingEventTile = mapEditorStore.map.mapEventTiles.find((eventTile) => eventTile.positionX === tile.x && eventTile.positionY === tile.y)
if (!existingEventTile) return 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 // Remove existing event tile
map.mapEventTiles = map.mapEventTiles.filter((eventTile) => eventTile.id !== existingEventTile.id) mapEditorStore.map.mapEventTiles = mapEditorStore.map.mapEventTiles.filter((eventTile) => eventTile.id !== existingEventTile.id)
} }
function handlePointer(pointer: Phaser.Input.Pointer) { onMounted(() => {
const map = mapEditor.currentMap.value scene.input.on(Phaser.Input.Events.POINTER_DOWN, pencil)
if (!map) return scene.input.on(Phaser.Input.Events.POINTER_MOVE, pencil)
scene.input.on(Phaser.Input.Events.POINTER_DOWN, eraser)
scene.input.on(Phaser.Input.Events.POINTER_MOVE, eraser)
})
if (pointer.event.altKey) return onUnmounted(() => {
scene.input.off(Phaser.Input.Events.POINTER_DOWN, pencil)
switch (mapEditor.tool.value) { scene.input.off(Phaser.Input.Events.POINTER_MOVE, pencil)
case 'pencil': scene.input.off(Phaser.Input.Events.POINTER_DOWN, eraser)
pencil(pointer, map) scene.input.off(Phaser.Input.Events.POINTER_MOVE, eraser)
break })
case 'eraser':
erase(pointer, map)
break
}
}
function clearTiles() {
if (mapEditor.currentMap.value?.mapEventTiles.length === 0) return
createCommandUpdate(null, 'clear')
finalizeCommand()
}
</script> </script>

View File

@ -1,157 +1,231 @@
<template> <template>
<Controls v-if="tileMapLayer" :layer="tileMapLayer" :depth="0" /> <Controls v-if="tileLayer" :layer="tileLayer" :depth="0" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { type EditorCommand } from '@/components/gameMaster/mapEditor/Map.vue' import config from '@/application/config'
import Controls from '@/components/utilities/Controls.vue' import Controls from '@/components/utilities/Controls.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { createTileArray, getTile, loadAllTilesIntoScene, placeTile, setLayerTiles } from '@/composables/mapComposable'
import { cloneArray, createTileArray, getTile, placeTile, placeTiles } from '@/services/mapService' import { TileStorage } from '@/storage/storages'
import { onMounted, ref, watch } from 'vue' import { useMapEditorStore } from '@/stores/mapEditorStore'
import { useScene } from 'phavuer'
import { onBeforeMount, onMounted, onUnmounted, shallowRef, watch } from 'vue'
const mapEditor = useMapEditorComposable() import Tileset = Phaser.Tilemaps.Tileset
defineExpose({ handlePointer, finalizeCommand, clearTiles }) const emit = defineEmits(['tileMap:create'])
const emit = defineEmits(['createCommand']) const scene = useScene()
const mapEditorStore = useMapEditorStore()
const tileStorage = new TileStorage()
const props = defineProps<{ const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
tileMap: Phaser.Tilemaps.Tilemap const tileLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
tileMapLayer: Phaser.Tilemaps.TilemapLayer
}>()
// *** COMMAND STATE *** function createTileMap() {
const mapData = new Phaser.Tilemaps.MapData({
width: mapEditorStore.map?.width,
height: mapEditorStore.map?.height,
tileWidth: config.tile_size.width,
tileHeight: config.tile_size.height,
orientation: Phaser.Tilemaps.Orientation.ISOMETRIC,
format: Phaser.Tilemaps.Formats.ARRAY_2D
})
let currentCommand: TileCommand | null = null const newTileMap = new Phaser.Tilemaps.Tilemap(scene, mapData)
emit('tileMap:create', newTileMap)
class TileCommand implements EditorCommand { return newTileMap
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') { async function createTileLayer(currentTileMap: Phaser.Tilemaps.Tilemap) {
if (!currentCommand) { const tiles = await tileStorage.getAll()
currentCommand = new TileCommand(operation, tileName) const tilesetImages = []
for (const tile of tiles) {
tilesetImages.push(currentTileMap.addTilesetImage(tile.id, tile.id, config.tile_size.width, config.tile_size.height, 1, 2, tilesetImages.length + 1, { x: 0, y: -config.tile_size.height }))
} }
//If position is already in, do not proceed // Add blank tile
for (const vec of currentCommand.affectedTiles) { tilesetImages.push(currentTileMap.addTilesetImage('blank_tile', 'blank_tile', config.tile_size.width, config.tile_size.height, 1, 2, 0, { x: 0, y: -config.tile_size.height }))
if (vec[0] === x && vec[1] === y) return
}
currentCommand.affectedTiles.push([x, y]) const layer = currentTileMap.createBlankLayer('tiles', tilesetImages as Tileset[], 0, config.tile_size.height) as Phaser.Tilemaps.TilemapLayer
layer.setDepth(0)
layer.setCullPadding(2, 2)
return layer
} }
function finalizeCommand() { function pencil(pointer: Phaser.Input.Pointer) {
if (!currentCommand) return if (!tileMap.value || !tileLayer.value) return
emit('createCommand', currentCommand)
currentCommand = null
}
// *** HANDLERS *** // Check if map is set
if (!mapEditorStore.map) return
function draw(pointer: Phaser.Input.Pointer, tileName: string) { // Check if tool is pencil
let map = mapEditor.currentMap.value if (mapEditorStore.tool !== 'pencil') return
if (!map) return
// Check if draw mode is tile
if (mapEditorStore.drawMode !== 'tile') return
// Check if there is a selected tile
if (!mapEditorStore.selectedTile) 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 there is a tile // Check if there is a tile
const tile = getTile(props.tileMapLayer, pointer.worldX, pointer.worldY) const tile = getTile(tileLayer.value, pointer.worldX, pointer.worldY)
if (!tile) return if (!tile) return
// Place tile // Place tile
placeTile(props.tileMap, props.tileMapLayer, tile.x, tile.y, tileName) placeTile(tileMap.value, tileLayer.value, tile.x, tile.y, mapEditorStore.selectedTile)
createCommandUpdate(tile.x, tile.y, tileName, tileName === 'blank_tile' ? 'erase' : 'draw')
// Adjust mapEditorStore.map.tiles // Adjust mapEditorStore.map.tiles
map.tiles[tile.y][tile.x] = tileName mapEditorStore.map.tiles[tile.y][tile.x] = mapEditorStore.selectedTile
} }
function paint(pointer: Phaser.Input.Pointer) { function eraser(pointer: Phaser.Input.Pointer) {
let map = mapEditor.currentMap.value if (!tileMap.value || !tileLayer.value) return
if (!map) return
// Set new tileArray with selected tile // Check if map is set
const tileArray = createTileArray(props.tileMap.width, props.tileMap.height, mapEditor.selectedTile.value) if (!mapEditorStore.map) return
placeTiles(props.tileMap, props.tileMapLayer, tileArray)
// Adjust mapEditorStore.map.tiles // Check if tool is pencil
map.tiles = tileArray if (mapEditorStore.tool !== 'eraser') return
}
// When alt is pressed, and the pointer is down, select the tile that the pointer is over // Check if draw mode is tile
function tilePicker(pointer: Phaser.Input.Pointer) { if (mapEditorStore.eraserMode !== 'tile') return
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 // Check if left mouse button is pressed
if (!pointer.isDown && pointer.button === 0) return if (!pointer.isDown) return
// Check if shift is not pressed, this means we are moving the camera // Check if shift is not pressed, this means we are moving the camera
if (pointer.event.shiftKey) return if (pointer.event.shiftKey) return
// Check if alt is pressed // Check if alt is pressed
if (pointer.event.altKey) { if (pointer.event.altKey) return
tilePicker(pointer)
return // Check if there is a tile
} const tile = getTile(tileLayer.value, pointer.worldX, pointer.worldY)
if (!tile) return
// Place tile
placeTile(tileMap.value, tileLayer.value, tile.x, tile.y, 'blank_tile')
// Adjust mapEditorStore.map.tiles
mapEditorStore.map.tiles[tile.y][tile.x] = 'blank_tile'
}
function paint(pointer: Phaser.Input.Pointer) {
if (!tileMap.value || !tileLayer.value) return
// Check if map is set
if (!mapEditorStore.map) return
// Check if tool is pencil
if (mapEditorStore.tool !== 'paint') return
// Check if there is a selected tile
if (!mapEditorStore.selectedTile) 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 alt is pressed
if (pointer.event.altKey) return
// Set new tileArray with selected tile
setLayerTiles(tileMap.value, tileLayer.value, createTileArray(tileMap.value.width, tileMap.value.height, mapEditorStore.selectedTile))
// Adjust mapEditorStore.map.tiles
mapEditorStore.map.tiles = createTileArray(tileMap.value.width, tileMap.value.height, mapEditorStore.selectedTile)
}
// When alt is pressed, and the pointer is down, select the tile that the pointer is over
function tilePicker(pointer: Phaser.Input.Pointer) {
if (!tileMap.value || !tileLayer.value) return
// Check if map is set
if (!mapEditorStore.map) return
// Check if tool is pencil
if (mapEditorStore.tool !== 'pencil') return
// Check if draw mode is tile // Check if draw mode is tile
switch (mapEditor.tool.value) { if (mapEditorStore.drawMode !== 'tile') return
case 'pencil':
draw(pointer, mapEditor.selectedTile.value!) // Check if left mouse button is pressed
break if (!pointer.isDown) return
case 'eraser':
draw(pointer, 'blank_tile') // Check if shift is not pressed, this means we are moving the camera
break if (pointer.event.shiftKey) return
case 'paint':
paint(pointer) // Check if alt is pressed
break if (!pointer.event.altKey) return
// Check if there is a tile
const tile = getTile(tileLayer.value, pointer.worldX, pointer.worldY)
if (!tile) return
// Select the tile
mapEditorStore.setSelectedTile(mapEditorStore.map.tiles[tile.y][tile.x])
}
watch(
() => mapEditorStore.shouldClearTiles,
(shouldClear) => {
if (shouldClear && mapEditorStore.map && tileMap.value && tileLayer.value) {
const blankTiles = createTileArray(tileMap.value.width, tileMap.value.height, 'blank_tile')
setLayerTiles(tileMap.value, tileLayer.value, blankTiles)
mapEditorStore.map.tiles = blankTiles
mapEditorStore.resetClearTilesFlag()
} }
} }
)
// *** 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 () => { onMounted(async () => {
if (!mapEditor.currentMap.value) return if (!mapEditorStore.map?.tiles) return
const mapState = mapEditor.currentMap.value
placeTiles(props.tileMap, props.tileMapLayer, mapState.tiles) tileMap.value = createTileMap()
tileLayer.value = await createTileLayer(tileMap.value)
// First fill the entire map with blank tiles using current map dimensions
const blankTiles = createTileArray(mapEditorStore.map.width, mapEditorStore.map.height, 'blank_tile')
// Then overlay the map tiles, but only within the current map dimensions
const mapTiles = mapEditorStore.map.tiles
for (let y = 0; y < mapEditorStore.map.height; y++) {
for (let x = 0; x < mapEditorStore.map.width; x++) {
if (mapTiles[y] && mapTiles[y][x] !== undefined) {
blankTiles[y][x] = mapTiles[y][x]
}
}
}
setLayerTiles(tileMap.value, tileLayer.value, blankTiles)
scene.input.on(Phaser.Input.Events.POINTER_MOVE, pencil)
scene.input.on(Phaser.Input.Events.POINTER_MOVE, eraser)
scene.input.on(Phaser.Input.Events.POINTER_DOWN, paint)
scene.input.on(Phaser.Input.Events.POINTER_DOWN, tilePicker)
})
onUnmounted(() => {
scene.input.off(Phaser.Input.Events.POINTER_MOVE, pencil)
scene.input.off(Phaser.Input.Events.POINTER_MOVE, eraser)
scene.input.off(Phaser.Input.Events.POINTER_DOWN, paint)
scene.input.off(Phaser.Input.Events.POINTER_DOWN, tilePicker)
if (tileMap.value) {
tileMap.value.destroyLayer('tiles')
tileMap.value.removeAllLayers()
tileMap.value.destroy()
}
}) })
</script> </script>

View File

@ -0,0 +1,45 @@
<template>
<Image v-if="gameStore.isTextureLoaded(props.placedMapObject.mapObject.id)" v-bind="imageProps" />
</template>
<script setup lang="ts">
import type { PlacedMapObject, TextureData } from '@/application/types'
import { loadTexture } from '@/composables/gameComposable'
import { calculateIsometricDepth, tileToWorldX, tileToWorldY } from '@/composables/mapComposable'
import { useGameStore } from '@/stores/gameStore'
import { Image, useScene } from 'phavuer'
import { computed } from 'vue'
const props = defineProps<{
tilemap: Phaser.Tilemaps.Tilemap
placedMapObject: PlacedMapObject
selectedPlacedMapObject: PlacedMapObject | null
movingPlacedMapObject: PlacedMapObject | null
}>()
const gameStore = useGameStore()
const scene = useScene()
const imageProps = computed(() => ({
alpha: props.movingPlacedMapObject?.id === props.placedMapObject.id ? 0.5 : 1,
tint: props.selectedPlacedMapObject?.id === props.placedMapObject.id ? 0x00ff00 : 0xffffff,
depth: calculateIsometricDepth(props.placedMapObject.positionX, props.placedMapObject.positionY, props.placedMapObject.mapObject.frameWidth, props.placedMapObject.mapObject.frameHeight),
x: tileToWorldX(props.tilemap, props.placedMapObject.positionX, props.placedMapObject.positionY),
y: tileToWorldY(props.tilemap, props.placedMapObject.positionX, props.placedMapObject.positionY),
flipX: props.placedMapObject.isRotated,
texture: props.placedMapObject.mapObject.id,
originY: Number(props.placedMapObject.mapObject.originX),
originX: Number(props.placedMapObject.mapObject.originY)
}))
loadTexture(scene, {
key: props.placedMapObject.mapObject.id,
data: '/textures/map_objects/' + props.placedMapObject.mapObject.id + '.png',
group: 'map_objects',
updatedAt: props.placedMapObject.mapObject.updatedAt,
frameWidth: props.placedMapObject.mapObject.frameWidth,
frameHeight: props.placedMapObject.mapObject.frameHeight
} as TextureData).catch((error) => {
console.error('Error loading texture:', error)
})
</script>

View File

@ -1,202 +1,246 @@
<template> <template>
<PlacedMapObject <SelectedPlacedMapObjectComponent v-if="selectedPlacedMapObject" :placedMapObject="selectedPlacedMapObject" @move="moveMapObject" @rotate="rotatePlacedMapObject" @delete="deletePlacedMapObject" />
v-if="mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'map_object' && mapEditor.isPlacedMapObjectPreviewEnabled.value && mapEditor.selectedMapObject.value && previewPlacedMapObject" <PlacedMapObject v-for="placedMapObject in mapEditorStore.map?.placedMapObjects" :tilemap="tilemap" :placedMapObject :selectedPlacedMapObject :movingPlacedMapObject @pointerup="clickPlacedMapObject(placedMapObject)" />
: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)" :key="`${placedMapObject.id}-${placedMapObjectKey}`" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapObject, Map as MapT, PlacedMapObject as PlacedMapObjectT } from '@/application/types' import type { PlacedMapObject as PlacedMapObjectT } from '@/application/types'
import { uuidv4 } from '@/application/utilities' import { uuidv4 } from '@/application/utilities'
import PlacedMapObject from '@/components/game/map/partials/PlacedMapObject.vue' import PlacedMapObject from '@/components/gameMaster/mapEditor/mapPartials/PlacedMapObject.vue'
import SelectedPlacedMapObjectComponent from '@/components/gameMaster/mapEditor/partials/SelectedPlacedMapObject.vue' import SelectedPlacedMapObjectComponent from '@/components/gameMaster/mapEditor/partials/SelectedPlacedMapObject.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { getTile } from '@/composables/mapComposable'
import { getTile } from '@/services/mapService' import { useMapEditorStore } from '@/stores/mapEditorStore'
import { useScene } from 'phavuer' import { useScene } from 'phavuer'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import Tilemap = Phaser.Tilemaps.Tilemap
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
const scene = useScene() const scene = useScene()
const mapEditor = useMapEditorComposable() const mapEditorStore = useMapEditorStore()
const map = computed(() => mapEditor.currentMap.value!) const selectedPlacedMapObject = ref<PlacedMapObjectT | null>(null)
const placedMapObjectKey = computed(() => mapEditor.refreshMapObject.value) const movingPlacedMapObject = ref<PlacedMapObjectT | null>(null)
const emit = defineEmits<{ (e: 'update', map: MapT): void; (e: 'updateAndCommit', map: MapT): void; (e: 'pauseObjectTracking'): void; (e: 'resumeObjectTracking'): void }>()
defineExpose({ handlePointer })
const props = defineProps<{ const props = defineProps<{
tileMap: Tilemap tilemap: Phaser.Tilemaps.Tilemap
tileMapLayer: TilemapLayer
}>() }>()
const previewPosition = ref({ x: 0, y: 0 }) function pencil(pointer: Phaser.Input.Pointer) {
const previewPlacedMapObject = computed(() => ({ // Check if map is set
id: mapEditor.selectedMapObject.value!.id, if (!mapEditorStore.map) return
mapObject: mapEditor.selectedMapObject.value!.id,
isRotated: false,
positionX: previewPosition.value.x,
positionY: previewPosition.value.y
}))
function updatePreviewPosition(pointer: Phaser.Input.Pointer) { // Check if tool is pencil
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY) if (mapEditorStore.tool !== 'pencil') return
if (!tile || (previewPosition.value.x === tile.x && previewPosition.value.y === tile.y)) return
previewPosition.value = { x: tile.x, y: tile.y } // Check if draw mode is map_object
} if (mapEditorStore.drawMode !== 'map_object') return
function pencil(pointer: Phaser.Input.Pointer, map: MapT) { // Check if there is a selected object
emit('pauseObjectTracking') if (!mapEditorStore.selectedMapObject) return
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
// 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 alt is pressed, this means we are selecting the object
if (pointer.event.altKey) return
// Check if there is a tile
const tile = getTile(props.tilemap, pointer.worldX, pointer.worldY)
if (!tile) return if (!tile) return
// Check if object already exists on position // Check if object already exists on position
const existingPlacedMapObject = findObjectByPointer(pointer, mapEditor.currentMap.value!) const existingPlacedMapObject = mapEditorStore.map.placedMapObjects.find((placedMapObject) => placedMapObject.positionX === tile.x && placedMapObject.positionY === tile.y)
if (existingPlacedMapObject) return if (existingPlacedMapObject) return
if (!mapEditor.selectedMapObject.value) return const newPlacedMapObject = {
const newPlacedMapObject: PlacedMapObjectT = {
id: uuidv4(), id: uuidv4(),
mapObject: mapEditor.selectedMapObject.value.id, map: mapEditorStore.map,
mapObject: mapEditorStore.selectedMapObject,
depth: 0,
isRotated: false, isRotated: false,
positionX: tile.x, positionX: tile.x,
positionY: tile.y positionY: tile.y
} }
// Add new object to mapObjects // Add new object to mapObjects
mapEditor.selectedPlacedObject.value = newPlacedMapObject mapEditorStore.map.placedMapObjects = mapEditorStore.map.placedMapObjects.concat(newPlacedMapObject as PlacedMapObjectT)
map.placedMapObjects.push(newPlacedMapObject)
emit('update', map)
} }
function eraser(pointer: Phaser.Input.Pointer, map: MapT) { function eraser(pointer: Phaser.Input.Pointer) {
emit('pauseObjectTracking') // Check if map is set
if (!mapEditorStore.map) return
// Check if object already exists on position // Check if tool is eraser
const existingPlacedMapObject = findObjectByPointer(pointer, map) if (mapEditorStore.tool !== 'eraser') return
if (!existingPlacedMapObject) return
// Remove existing object // Check if draw mode is map_object
map.placedMapObjects = map.placedMapObjects.filter((placedMapObject) => placedMapObject.id !== existingPlacedMapObject.id) if (mapEditorStore.eraserMode !== 'map_object') return
emit('update', map) // Check if left mouse button is pressed
} if (!pointer.isDown) return
function findObjectByPointer(pointer: Phaser.Input.Pointer, map: MapT): PlacedMapObjectT | undefined { // Check if shift is not pressed, this means we are moving the camera
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY) if (pointer.event.shiftKey) return
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
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 // Check if alt is pressed, this means we are selecting the object
if (pointer.event.altKey) return if (pointer.event.altKey) return
// Check if there is a tile
const tile = getTile(props.tilemap, pointer.worldX, pointer.worldY)
if (!tile) return
// Check if object already exists on position
const existingPlacedMapObject = mapEditorStore.map.placedMapObjects.find((placedMapObject) => placedMapObject.positionX === tile.x && placedMapObject.positionY === tile.y)
if (!existingPlacedMapObject) return
// Remove existing object
mapEditorStore.map.placedMapObjects = mapEditorStore.map.placedMapObjects.filter((placedMapObject) => placedMapObject.id !== existingPlacedMapObject.id)
}
function objectPicker(pointer: Phaser.Input.Pointer) {
// Check if map is set
if (!mapEditorStore.map) return
// Check if tool is pencil // Check if tool is pencil
switch (mapEditor.tool.value) { if (mapEditorStore.tool !== 'pencil') return
case 'pencil':
pencil(pointer, map) // Check if draw mode is map_object
break if (mapEditorStore.drawMode !== 'map_object') return
case 'eraser':
eraser(pointer, map) // Check if left mouse button is pressed
break if (!pointer.isDown) return
case 'object picker':
objectPicker(pointer, map) // Check if shift is not pressed, this means we are moving the camera
break if (pointer.event.shiftKey) return
// If alt is not pressed, return
if (!pointer.event.altKey) return
// Check if there is a tile
const tile = getTile(props.tilemap, pointer.worldX, pointer.worldY)
if (!tile) return
// Check if object already exists on position
const existingPlacedMapObject = mapEditorStore.map.placedMapObjects.find((placedMapObject) => placedMapObject.positionX === tile.x && placedMapObject.positionY === tile.y)
if (!existingPlacedMapObject) return
// Select the object
mapEditorStore.setSelectedMapObject(existingPlacedMapObject.mapObject)
}
function moveMapObject(id: string) {
// Check if map is set
if (!mapEditorStore.map) return
movingPlacedMapObject.value = mapEditorStore.map.placedMapObjects.find((object) => object.id === id) as PlacedMapObjectT
function handlePointerMove(pointer: Phaser.Input.Pointer) {
if (!movingPlacedMapObject.value) return
const tile = getTile(props.tilemap, pointer.worldX, pointer.worldY)
if (!tile) return
movingPlacedMapObject.value.positionX = tile.x
movingPlacedMapObject.value.positionY = tile.y
}
scene.input.on(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
function handlePointerUp() {
scene.input.off(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
movingPlacedMapObject.value = null
}
scene.input.on(Phaser.Input.Events.POINTER_UP, handlePointerUp)
}
function rotatePlacedMapObject(id: string) {
// Check if map is set
if (!mapEditorStore.map) return
mapEditorStore.map.placedMapObjects = mapEditorStore.map.placedMapObjects.map((placedMapObject) => {
if (placedMapObject.id === id) {
return {
...placedMapObject,
isRotated: !placedMapObject.isRotated
}
}
return placedMapObject
})
}
function deletePlacedMapObject(id: string) {
// Check if map is set
if (!mapEditorStore.map) return
mapEditorStore.map.placedMapObjects = mapEditorStore.map.placedMapObjects.filter((object) => object.id !== id)
selectedPlacedMapObject.value = null
}
function clickPlacedMapObject(placedMapObject: PlacedMapObjectT) {
selectedPlacedMapObject.value = placedMapObject
// If alt is pressed, select the object
if (scene.input.activePointer.event.altKey) {
mapEditorStore.setSelectedMapObject(placedMapObject.mapObject)
} }
} }
onMounted(() => { onMounted(() => {
scene.input.on(Phaser.Input.Events.POINTER_MOVE, updatePreviewPosition) scene.input.on(Phaser.Input.Events.POINTER_DOWN, pencil)
scene.input.on(Phaser.Input.Events.POINTER_MOVE, pencil)
scene.input.on(Phaser.Input.Events.POINTER_DOWN, eraser)
scene.input.on(Phaser.Input.Events.POINTER_MOVE, eraser)
scene.input.on(Phaser.Input.Events.POINTER_DOWN, objectPicker)
}) })
onUnmounted(() => { onUnmounted(() => {
scene.input.off(Phaser.Input.Events.POINTER_MOVE, updatePreviewPosition) scene.input.off(Phaser.Input.Events.POINTER_DOWN, pencil)
scene.input.off(Phaser.Input.Events.POINTER_MOVE, pencil)
scene.input.off(Phaser.Input.Events.POINTER_DOWN, eraser)
scene.input.off(Phaser.Input.Events.POINTER_MOVE, eraser)
scene.input.off(Phaser.Input.Events.POINTER_DOWN, objectPicker)
}) })
// watch mapEditorStore.mapObjectList and update originX and originY of objects in mapObjects
watch(
() => mapEditorStore.mapObjectList,
(newMapObjects) => {
if (!mapEditorStore.map) return
const updatedMapObjects = mapEditorStore.map.placedMapObjects.map((mapObject) => {
const updatedMapObject = newMapObjects.find((obj) => obj.id === mapObject.mapObject.id)
if (updatedMapObject) {
return {
...mapObject,
mapObject: {
...mapObject.mapObject,
originX: updatedMapObject.originX,
originY: updatedMapObject.originY
}
}
}
return mapObject
})
// Update the map with the new mapObjects
mapEditorStore.setMap({
...mapEditorStore.map,
placedMapObjects: updatedMapObjects
})
// Update selectedMapObject if it's set
if (mapEditorStore.selectedMapObject) {
const updatedMapObject = newMapObjects.find((obj) => obj.id === mapEditorStore.selectedMapObject?.id)
if (updatedMapObject) {
mapEditorStore.setSelectedMapObject({
...mapEditorStore.selectedMapObject,
originX: updatedMapObject.originX,
originY: updatedMapObject.originY
})
}
}
}
// { deep: true }
)
</script> </script>

View File

@ -1,8 +1,9 @@
<template> <template>
<Modal ref="modalRef" :modal-width="300" :modal-height="420" :is-resizable="false" bg-style="none"> <Modal :isModalOpen="true" @modal:close="() => mapEditorStore.toggleCreateMapModal()" :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 map</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">
@ -13,15 +14,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="width" id="width" type="number" /> <input class="input-field max-w-64" v-model="width" name="name" id="name" 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="height" id="height" type="number" /> <input class="input-field max-w-64" v-model="height" name="name" id="name" 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" v-model="pvp" name="pvp" id="pvp"> <select class="input-field" 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>
@ -35,48 +36,23 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import type { Map } from '@/application/types' import type { Map } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue' import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { socketManager } from '@/managers/SocketManager'
import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { ref, useTemplateRef } from 'vue' import { useMapEditorStore } from '@/stores/mapEditorStore'
import { ref } from 'vue'
const emit = defineEmits(['create'])
const gameStore = useGameStore() const gameStore = useGameStore()
const mapStorage = new MapStorage() const mapEditorStore = useMapEditorStore()
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)
defineExpose({ open: () => modalRef.value?.open() }) function submit() {
gameStore.connection?.emit('gm:map:create', { name: name.value, width: width.value, height: height.value }, (response: Map[]) => {
async function submit() { mapEditorStore.setMapList(response)
socketManager.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')
}) })
mapEditorStore.toggleCreateMapModal()
// Close modal
modalRef.value?.close()
} }
</script> </script>

View File

@ -1,15 +1,17 @@
<template> <template>
<Modal ref="modalRef" :is-resizable="false" :modal-width="300" :modal-height="360" bg-style="none"> <CreateMap v-if="mapEditorStore.isCreateMapModalShown" />
<Modal :is-modal-open="mapEditorStore.isMapListModalShown" @modal:close="() => mapEditorStore.toggleMapListModal()" :is-resizable="false" :modal-width="300" :modal-height="360" :bg-style="'none'">
<template #modalHeader> <template #modalHeader>
<div class="flex items-center"> <h3 class="text-lg text-white">Maps</h3>
<button class="btn-cyan w-7 h-7 font-normal flex items-center justify-center" @click="createMapModal?.open">+</button>
<h3 class="text-lg text-white ml-2">Maps</h3>
</div>
</template> </template>
<template #modalBody> <template #modalBody>
<div class="mx-auto h-full"> <div class="my-4 mx-auto">
<div class="overflow-y-auto h-[calc(100%)]"> <div class="text-center mb-4 px-2 flex gap-2.5">
<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"> <button class="btn-cyan py-1.5 min-w-[calc(50%_-_5px)]" @click="fetchMaps">Refresh</button>
<button class="btn-cyan py-1.5 min-w-[calc(50%_-_5px)]" @click="() => mapEditorStore.toggleCreateMapModal()">New</button>
</div>
<div class="relative p-2.5 cursor-pointer flex gap-y-2.5 gap-x-5 flex-wrap" v-for="(map, index) in mapEditorStore.mapList" :key="map.id">
<div class="absolute left-0 top-0 w-full h-px bg-gray-500" v-if="index === 0"></div>
<div class="flex gap-3 items-center w-full" @click="() => loadMap(map.id)"> <div class="flex gap-3 items-center w-full" @click="() => loadMap(map.id)">
<span>{{ map.name }}</span> <span>{{ map.name }}</span>
<span class="ml-auto gap-1 flex"> <span class="ml-auto gap-1 flex">
@ -19,64 +21,41 @@
<div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div> <div class="absolute left-0 bottom-0 w-full h-px bg-gray-500"></div>
</div> </div>
</div> </div>
</div>
</template> </template>
</Modal> </Modal>
<CreateMap ref="createMapModal" @create="fetchMaps" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import type { Map, UUID } from '@/application/types'
import type { Map } from '@/application/types'
import CreateMap from '@/components/gameMaster/mapEditor/partials/CreateMap.vue' import CreateMap from '@/components/gameMaster/mapEditor/partials/CreateMap.vue'
import Modal from '@/components/utilities/Modal.vue' import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { socketManager } from '@/managers/SocketManager'
import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore' import { useGameStore } from '@/stores/gameStore'
import { onMounted, ref, useTemplateRef } from 'vue' import { useMapEditorStore } from '@/stores/mapEditorStore'
import { onMounted } from 'vue'
const gameStore = useGameStore() const gameStore = useGameStore()
const mapEditorStore = useMapEditorStore()
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 () => { onMounted(async () => {
await fetchMaps() fetchMaps()
}) })
async function fetchMaps() { function fetchMaps() {
mapList.value = await mapStorage.getAll() gameStore.connection?.emit('gm:map:list', {}, (response: Map[]) => {
mapEditorStore.setMapList(response)
})
} }
function loadMap(id: string) { function loadMap(id: UUID) {
socketManager.emit(SocketEvent.GM_MAP_REQUEST, { mapId: id }, (response: Map) => { gameStore.connection?.emit('gm:map:request', { mapId: id }, (response: Map) => {
mapEditor.loadMap(response) mapEditorStore.setMap(response)
}) })
modalRef.value?.close() mapEditorStore.toggleMapListModal()
} }
async function deleteMap(id: string) { function deleteMap(id: UUID) {
socketManager.emit(SocketEvent.GM_MAP_DELETE, { mapId: id }, async (response: boolean) => { gameStore.connection?.emit('gm:map:delete', { mapId: id }, () => {
if (!response) { fetchMaps()
gameStore.addNotification({
title: 'Error',
message: 'Failed to delete map.'
})
return
}
await mapStorage.delete(id)
await fetchMaps()
}) })
} }
</script> </script>

View File

@ -1,70 +1,72 @@
<template> <template>
<div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'map_object'"> <Modal :isModalOpen="mapEditorStore.isMapObjectListModalShown" :modal-width="645" :modal-height="260" @modal:close="() => (mapEditorStore.isMapObjectListModalShown = false)" :bg-style="'none'">
<div class="flex flex-col gap-2.5 p-2.5"> <template #modalHeader>
<div class="flex justify-between items-center"> <h3 class="text-lg text-white">Map objects</h3>
<div class="flex-grow"> </template>
<div class="relative flex"> <template #modalBody>
<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" /> <div class="flex pt-4 pl-4">
<label class="mb-1.5 font-titles hidden" for="search">Search</label> <div class="w-full flex gap-1.5 flex-row">
<input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" /> <div>
<label class="mb-1.5 font-titles hidden" for="search">Search...</label>
<input @mousedown.stop class="input-field" type="text" name="search" placeholder="Search" v-model="searchQuery" />
</div> </div>
</div> </div>
<img src="/assets/icons/mapEditor/dropdown-chevron.svg" class="w-12 h-12 ml-2 cursor-pointer hover:opacity-80 -rotate-90" alt="Close" @click="mapEditor.setTool('move')" />
</div> </div>
<div class="flex"> <div class="flex flex-col h-full p-4">
<select class="input-field w-full" name="lists" v-model="mapEditor.drawMode.value" @change="(event: any) => mapEditor.setDrawMode(event.target.value)"> <div class="mb-4 flex flex-wrap gap-2">
<option value="tile">Tiles</option> <button v-for="tag in uniqueTags" :key="tag" @click="toggleTag(tag)" class="btn-cyan" :class="{ 'opacity-50': !selectedTags.includes(tag) }">
<option value="map_object">Objects</option> {{ tag }}
</select> </button>
</div> </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="h-full overflow-auto">
<div class="flex justify-between flex-wrap gap-2.5 items-center"> <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"> <div v-for="(mapObject, index) in filteredMapObjects" :key="index" class="max-w-1/4 inline-block">
<img <img
class="border-2 border-solid rounded max-w-full" class="border-2 border-solid max-w-full"
:src="`${config.server_endpoint}/textures/map_objects/${mapObject.id}.png`" :src="`${config.server_endpoint}/textures/map_objects/${mapObject.id}.png`"
alt="Object" alt="Object"
@click="mapEditor.setSelectedMapObject(mapObject)" @click="mapEditorStore.setSelectedMapObject(mapObject)"
:class="{ :class="{
'cursor-pointer transition-all duration-300': true, 'cursor-pointer transition-all duration-300': true,
'border-cyan shadow-lg': mapEditor.selectedMapObject.value?.id === mapObject.id, 'border-cyan shadow-lg scale-105': mapEditorStore.selectedMapObject?.id === mapObject.id,
'border-transparent hover:border-gray-300': mapEditor.selectedMapObject.value?.id !== mapObject.id 'border-transparent hover:border-gray-300': mapEditorStore.selectedMapObject?.id !== mapObject.id
}" }"
/> />
</div> </div>
</div> </div>
</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"> </template>
<span>Tags:</span> </Modal>
<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> </template>
<script setup lang="ts"> <script setup lang="ts">
import config from '@/application/config' import config from '@/application/config'
import type { MapObject } from '@/application/types' import type { MapObject } from '@/application/types'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import Modal from '@/components/utilities/Modal.vue'
import { MapObjectStorage } from '@/storage/storages' import { useGameStore } from '@/stores/gameStore'
import { liveQuery } from 'dexie' import { useMapEditorStore } from '@/stores/mapEditorStore'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
const mapObjectStorage = new MapObjectStorage() const gameStore = useGameStore()
const mapEditor = useMapEditorComposable() const isModalOpen = ref(false)
const mapEditorStore = useMapEditorStore()
const searchQuery = ref('') const searchQuery = ref('')
const selectedTags = ref<string[]>([]) const selectedTags = ref<string[]>([])
const mapObjectList = ref<MapObject[]>([])
const uniqueTags = computed(() => { const uniqueTags = computed(() => {
const allTags = mapObjectList.value.flatMap((obj) => obj.tags || []) const allTags = mapEditorStore.mapObjectList.flatMap((obj) => obj.tags || [])
return Array.from(new Set(allTags)) return Array.from(new Set(allTags))
}) })
const filteredMapObjects = computed(() => {
return mapEditorStore.mapObjectList.filter((object) => {
const matchesSearch = !searchQuery.value || object.name.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesTags = selectedTags.value.length === 0 || (object.tags && selectedTags.value.some((tag) => object.tags.includes(tag)))
return matchesSearch && matchesTags
})
})
const toggleTag = (tag: string) => { const toggleTag = (tag: string) => {
if (selectedTags.value.includes(tag)) { if (selectedTags.value.includes(tag)) {
selectedTags.value = selectedTags.value.filter((t) => t !== tag) selectedTags.value = selectedTags.value.filter((t) => t !== tag)
@ -73,29 +75,10 @@ const toggleTag = (tag: string) => {
} }
} }
const filteredMapObjects = computed(() => { onMounted(async () => {
return mapObjectList.value.filter((object) => { isModalOpen.value = true
const matchesSearch = !searchQuery.value || object.name.toLowerCase().includes(searchQuery.value.toLowerCase()) gameStore.connection?.emit('gm:mapObject:list', {}, (response: MapObject[]) => {
const matchesTags = selectedTags.value.length === 0 || (object.tags && selectedTags.value.some((tag) => object.tags.includes(tag))) mapEditorStore.setMapObjectList(response)
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> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<Modal ref="modalRef" :modal-width="600" :modal-height="430" bg-style="none"> <Modal :is-modal-open="mapEditorStore.isSettingsModalShown" @modal:close="() => mapEditorStore.toggleSettingsModal()" :modal-width="600" :modal-height="430" :bg-style="'none'">
<template #modalHeader> <template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">Map settings</h3> <h3 class="m-0 font-medium shrink-0 text-white">Map settings</h3>
</template> </template>
@ -14,19 +14,22 @@
<div class="gap-2.5 flex flex-wrap mt-4"> <div class="gap-2.5 flex flex-wrap mt-4">
<div class="form-field-full"> <div class="form-field-full">
<label for="name">Name</label> <label for="name">Name</label>
<input class="input-field" v-model="name" @input="updateValue" name="name" id="name" /> <input class="input-field" v-model="name" name="name" id="name" />
</div> </div>
<div class="form-field-half"> <div class="form-field-half">
<label for="width">Width</label> <label for="width">Width</label>
<input class="input-field" v-model="width" @input="updateValue" name="width" id="width" type="number" /> <input class="input-field" v-model="width" name="width" id="width" type="number" />
</div> </div>
<div class="form-field-half"> <div class="form-field-half">
<label for="height">Height</label> <label for="height">Height</label>
<input class="input-field" v-model="height" @input="updateValue" name="height" id="height" type="number" /> <input class="input-field" v-model="height" name="height" id="height" type="number" />
</div> </div>
<div> <div class="form-field-full">
<label class="mr-4" for="pvp">PVP enabled</label> <label for="pvp">PVP enabled</label>
<input type="checkbox" v-model="pvp" @input="updateValue" class="input-field scale-125" name="pvp" id="pvp" /> <select v-model="pvp" class="input-field" name="pvp" id="pvp">
<option :value="false">No</option>
<option :value="true">Yes</option>
</select>
</div> </div>
</div> </div>
</form> </form>
@ -44,55 +47,60 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { UUID } from '@/application/types'
import { uuidv4 } from '@/application/utilities'
import Modal from '@/components/utilities/Modal.vue' import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { useMapEditorStore } from '@/stores/mapEditorStore'
import { onMounted, ref, useTemplateRef, watch } from 'vue' import { ref, watch } from 'vue'
const mapEditor = useMapEditorComposable() const mapEditorStore = useMapEditorStore()
const screen = ref('settings') const screen = ref('settings')
const name = ref<string | undefined>('Map') mapEditorStore.setMapName(mapEditorStore.map?.name)
const width = ref<number>(0) mapEditorStore.setMapWidth(mapEditorStore.map?.width)
const height = ref<number>(0) mapEditorStore.setMapHeight(mapEditorStore.map?.height)
const pvp = ref<boolean>(false) mapEditorStore.setMapPvp(mapEditorStore.map?.pvp)
const mapEffects = ref(mapEditor.currentMap.value?.mapEffects || []) mapEditorStore.setMapEffects(mapEditorStore.map?.mapEffects)
const modalRef = useTemplateRef('modalRef')
defineExpose({ const name = ref(mapEditorStore.mapSettings?.name)
open: () => modalRef.value?.open() const width = ref(mapEditorStore.mapSettings?.width)
const height = ref(mapEditorStore.mapSettings?.height)
const pvp = ref(mapEditorStore.mapSettings?.pvp)
const mapEffects = ref(mapEditorStore.mapSettings?.mapEffects || [])
watch(name, (value) => {
mapEditorStore.setMapName(value)
}) })
function updateValue(event: Event) { watch(width, (value) => {
let ev = event.target as HTMLInputElement mapEditorStore.setMapWidth(value)
const value = ev.name === 'pvp' ? (ev.checked ? 1 : 0) : ev.value })
mapEditor.updateProperty(ev.name as 'name' | 'width' | 'height' | 'pvp' | 'mapEffects', value)
} watch(height, (value) => {
mapEditorStore.setMapHeight(value)
})
watch(pvp, (value) => {
mapEditorStore.setMapPvp(value)
})
watch( watch(
() => mapEditor.currentMap.value, mapEffects,
(map) => { (value) => {
if (!map) return mapEditorStore.setMapEffects(value)
name.value = map.name },
width.value = map.width { deep: true }
height.value = map.height
pvp.value = map.pvp
mapEffects.value = map.mapEffects
}
) )
const addEffect = () => { const addEffect = () => {
mapEffects.value.push({ mapEffects.value.push({
id: uuidv4(), id: Date.now().toString(), // Simple unique id generation
mapId: mapEditorStore.map?.id,
map: mapEditorStore.map,
effect: '', effect: '',
strength: 1 strength: 1
}) })
mapEditor.updateProperty('mapEffects', mapEffects.value)
} }
const removeEffect = (index: number) => { const removeEffect = (index) => {
mapEffects.value.splice(index, 1) mapEffects.value.splice(index, 1)
mapEditor.updateProperty('mapEffects', mapEffects.value)
} }
</script> </script>

View File

@ -1,115 +1,33 @@
<template> <template>
<div class="flex flex-col items-center px-5 py-1 fixed bottom-20 left-0 z-20"> <div class="flex flex-col items-center py-5 px-3 fixed bottom-14 right-0">
<div class="flex h-10 gap-2"> <div class="self-end mt-2 flex gap-2">
<button @mousedown.stop @click="handleDelete" class="btn-red !py-3 px-4"> <button @mousedown.stop @click="handleDelete" class="btn-red py-1.5 px-4">
<img src="/assets/icons/trashcan.svg" class="w-4 h-4" alt="Delete" /> <img src="/assets/icons/trashcan.svg" class="w-4 h-4" alt="Delete" />
</button> </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="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> <button @mousedown.stop @click="handleMove" class="btn-cyan py-1.5 px-4 min-w-24">Move</button>
</div> </div>
</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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SocketEvent } from '@/application/enums' import type { PlacedMapObject } from '@/application/types'
import type { MapObject, Map as MapT, PlacedMapObject } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { socketManager } from '@/managers/SocketManager'
import { MapObjectStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { onMounted, ref } from 'vue'
const props = defineProps<{ const props = defineProps<{
placedMapObject: PlacedMapObject placedMapObject: PlacedMapObject
map: MapT
}>() }>()
const emit = defineEmits(['move', 'rotate', 'delete']) 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 = () => { const handleMove = () => {
emit('move', props.placedMapObject.id, props.map) emit('move', props.placedMapObject.id)
} }
const handleRotate = () => { const handleRotate = () => {
emit('rotate', props.placedMapObject.id, props.map) emit('rotate', props.placedMapObject.id)
} }
const handleDelete = () => { const handleDelete = () => {
emit('delete', props.placedMapObject.id, props.map) emit('delete', props.placedMapObject.id)
} }
async function handleUpdate() {
if (!mapObject.value) return
socketManager.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> </script>

View File

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

Some files were not shown because too many files have changed in this diff Show More