Compare commits

..

No commits in common. "main" and "feature/#325-attack" have entirely different histories.

118 changed files with 2230 additions and 9415 deletions

View File

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

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;
}
}

1578
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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,7 +1,8 @@
<template>
<Debug />
<Notifications />
<GmPanel v-if="gameStore.character?.role === 'gm'" />
<BackgroundImageLoader />
<GmPanel v-if="gameStore.character?.role === 'gm'" @open-map-editor="mapEditor.toggleActive" />
<component :is="currentScreen" />
</template>
@ -12,23 +13,21 @@ import Game from '@/components/screens/Game.vue'
import Loading from '@/components/screens/Loading.vue'
import Login from '@/components/screens/Login.vue'
import MapEditor from '@/components/screens/MapEditor.vue'
import BackgroundImageLoader from '@/components/utilities/BackgroundImageLoader.vue'
import Debug from '@/components/utilities/Debug.vue'
import Notifications from '@/components/utilities/Notifications.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { useSoundComposable } from '@/composables/useSoundComposable'
import { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore'
import { computed, watch } from 'vue'
import { useMapEditorStore } from '@/stores/mapEditorStore'
import { computed, ref, useTemplateRef, watch } from 'vue'
const gameStore = useGameStore()
const mapEditor = useMapEditorComposable()
const { playSound } = useSoundComposable()
const currentScreen = computed(() => {
if (!gameStore.game.isLoaded) return Loading
if (!socketManager.connection) return Login
if (!socketManager.token) return Login
if (!gameStore.connection) return Login
if (!gameStore.token) return Login
if (!gameStore.character) return Characters
if (mapEditor.active.value) return MapEditor
return Game
@ -43,13 +42,13 @@ watch(
)
// #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) => {
const classList = ['btn-cyan', 'btn-red', 'btn-indigo', 'btn-empty', 'btn-sound']
const target = event.target as HTMLElement
// console.log(target) // Uncomment to log the clicked element
if (classList.some((className) => target.classList.contains(className))) {
playSound('/assets/sounds/button-click.wav')
if (!(event.target instanceof HTMLButtonElement)) {
return
}
const audio = new Audio('/assets/music/click-btn.mp3')
audio.play()
})
// Watch for "G" key press and toggle the gm panel

View File

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

View File

@ -3,61 +3,3 @@ export enum Direction {
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

@ -26,7 +26,7 @@ export type TextureData = {
}
export type Tile = {
id: string
id: UUID
name: string
tags: any | null
createdAt: Date
@ -34,10 +34,9 @@ export type Tile = {
}
export type MapObject = {
id: string
id: UUID
name: string
tags: string[]
depthOffsets: number[]
tags: any | null
originX: number
originY: number
frameRate: number
@ -48,7 +47,7 @@ export type MapObject = {
}
export type Item = {
id: string
id: UUID
name: string
description: string | null
itemType: ItemType
@ -63,7 +62,7 @@ export type ItemType = 'WEAPON' | 'HELMET' | 'CHEST' | 'LEGS' | 'BOOTS' | 'GLOVE
export type ItemRarity = 'COMMON' | 'UNCOMMON' | 'RARE' | 'EPIC' | 'LEGENDARY'
export type Map = {
id: string
id: UUID
name: string
width: number
height: number
@ -79,14 +78,17 @@ export type Map = {
}
export type MapEffect = {
id: string
id: UUID
map: Map
effect: string
strength: number
}
export type PlacedMapObject = {
id: string
mapObject: MapObject | string
id: UUID
map: Map
mapObject: MapObject
depth: number
isRotated: boolean
positionX: number
positionY: number
@ -100,8 +102,8 @@ export enum MapEventTileType {
}
export type MapEventTile = {
id: string
map: string
id: UUID
mapId: UUID
type: MapEventTileType
positionX: number
positionY: number
@ -109,7 +111,7 @@ export type MapEventTile = {
}
export type MapEventTileTeleport = {
id: string
id: UUID
mapEventTile: MapEventTile
toMap: Map
toPositionX: number
@ -118,7 +120,7 @@ export type MapEventTileTeleport = {
}
export type User = {
id: string
id: UUID
username: string
password: string
characters: Character[]
@ -138,7 +140,7 @@ export enum CharacterRace {
}
export type CharacterType = {
id: string
id: UUID
name: string
gender: CharacterGender
race: CharacterRace
@ -149,17 +151,16 @@ export type CharacterType = {
}
export type CharacterHair = {
id: string
id: UUID
name: string
sprite: string | Sprite
sprite?: Sprite
gender: CharacterGender
color: string
isSelectable: boolean
}
export type Character = {
id: string
userid: string
id: UUID
userId: UUID
user: User
name: string
hitpoints: number
@ -186,14 +187,14 @@ export type MapCharacter = {
}
export type CharacterItem = {
id: string
id: UUID
character: Character
item: Item
quantity: number
}
export type CharacterEquipment = {
id: string
id: UUID
slot: CharacterEquipmentSlotType
characterItem: CharacterItem
}
@ -208,10 +209,8 @@ export enum CharacterEquipmentSlotType {
}
export type Sprite = {
id: string
id: UUID
name: string
width: number | null
height: number | null
createdAt: Date
updatedAt: Date
spriteActions: SpriteAction[]
@ -257,6 +256,6 @@ export type WeatherState = {
}
export type mapLoadData = {
mapId: string
mapId: UUID
characters: MapCharacter[]
}

View File

@ -7,8 +7,25 @@ export function uuidv4() {
}
export function unduplicateArray(array: any[]) {
const arrayToProcess = typeof array.flat === 'function' ? array.flat() : array
return [...new Set(arrayToProcess)]
return [...new Set(array.flat())]
}
export function getDomain() {
// Check if not localhost
if (window.location.hostname !== 'localhost') {
return window.location.hostname
}
// Check if not IP address
if (window.location.hostname.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
return window.location.hostname
}
if (window.location.hostname.split('.').length < 3) {
return window.location.hostname
}
return window.location.hostname.split('.').slice(-2).join('.')
}
export async function downloadCache<T extends { id: string; updatedAt: Date }>(endpoint: string, storage: BaseStorage<T>) {
@ -21,20 +38,10 @@ export async function downloadCache<T extends { id: string; updatedAt: Date }>(e
}
const items = response.data ?? []
const serverItemIds = new Set(items.map((item) => item.id))
// Remove items that don't exist on server
const existingItems = await storage.getAll()
for (const existingItem of existingItems) {
if (!serverItemIds.has(existingItem.id)) {
await storage.delete(existingItem.id)
}
}
// Update or add new items
for (const item of items) {
let overwrite = false
const existingItem = await storage.getById(item.id)
const existingItem = await storage.get(item.id)
if (!existingItem || item.updatedAt > existingItem.updatedAt) {
overwrite = true

View File

@ -73,7 +73,7 @@ input {
}
.input-field {
@apply px-4 py-2.5 text-base leading-5 bg-gray border border-solid border-gray-500 rounded text-gray-300 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 {
@apply outline-none border-cyan rounded bg-gray-900;
}
@ -88,12 +88,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 {
@apply w-full flex flex-col mb-5;
label {
@ -124,16 +118,7 @@ button {
&.active,
&:hover {
@apply bg-red-500;
}
}
&.btn-indigo {
@apply bg-indigo-500 text-gray-50 text-base leading-5 rounded py-2.5;
&.active,
&:hover {
@apply bg-indigo-600;
@apply bg-red-400;
}
}
@ -164,10 +149,6 @@ button {
@apply bg-gray bg-none;
}
.list-open {
@apply w-[calc(75%_-_40px)] max-xl:w-[calc(100%_-_360px)];
}
.hair-deselect:has(:checked) {
img {
@apply brightness-200;

View File

@ -2,25 +2,25 @@
<Container ref="characterContainer" :x="currentPositionX" :y="currentPositionY" :depth="isometricDepth">
<ChatBubble :mapCharacter="props.mapCharacter" />
<HealthBar :mapCharacter="props.mapCharacter" />
<CharacterHair :mapCharacter="props.mapCharacter" :flipX="isFlippedX" />
<Sprite ref="characterSprite" :flipX="isFlippedX" />
<CharacterHair :mapCharacter="props.mapCharacter" />
<Sprite ref="characterSprite" :origin-y="1" :flipX="isFlippedX" />
</Container>
</template>
<script lang="ts" setup>
import { Direction } from '@/application/enums'
import { type MapCharacter } from '@/application/types'
import CharacterHair from '@/components/game/character/partials/CharacterHair.vue'
import ChatBubble from '@/components/game/character/partials/ChatBubble.vue'
import HealthBar from '@/components/game/character/partials/HealthBar.vue'
import { useCharacterSpriteComposable } from '@/composables/useCharacterSpriteComposable'
import { useSoundComposable } from '@/composables/useSoundComposable'
import { useCharacterSprite } from '@/composables/useCharacterSpriteComposable'
import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore'
import { Container, Sprite, useScene } from 'phavuer'
import { onMounted, onUnmounted, watch } from 'vue'
const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap
tilemap: Phaser.Tilemaps.Tilemap
mapCharacter: MapCharacter
}>()
@ -28,14 +28,14 @@ const gameStore = useGameStore()
const mapStore = useMapStore()
const scene = useScene()
const { characterContainer, characterSprite, currentPositionX, currentPositionY, isometricDepth, isFlippedX, updatePosition, playAnimation, updateSprite, initializeSprite, cleanup } = useCharacterSpriteComposable(scene, props.tileMap, props.mapCharacter)
const { playSound, stopSound } = useSoundComposable()
const { characterContainer, characterSprite, currentPositionX, currentPositionY, isometricDepth, isFlippedX, updatePosition, calcDirection, updateSprite, initializeSprite, cleanup } = useCharacterSprite(scene, props.tilemap, props.mapCharacter)
const handlePositionUpdate = (newValues: any, oldValues: any) => {
if (!newValues) return
if (!oldValues || newValues.positionX !== oldValues.positionX || newValues.positionY !== oldValues.positionY) {
updatePosition(newValues.positionX, newValues.positionY)
const direction = !oldValues ? Direction.POSITIVE : calcDirection(oldValues.positionX, oldValues.positionY, newValues.positionX, newValues.positionY)
updatePosition(newValues.positionX, newValues.positionY, direction)
}
if (newValues.isMoving !== oldValues?.isMoving || newValues.rotation !== oldValues?.rotation) {
@ -43,39 +43,6 @@ const handlePositionUpdate = (newValues: any, oldValues: any) => {
}
}
/**
* Plays walk sound when character is moving
*/
watch(
() => props.mapCharacter.isMoving,
(newValue) => {
if (newValue) {
playSound('/assets/sounds/walk.wav', false, true)
} else {
stopSound('/assets/sounds/walk.wav')
}
}
)
/**
* Plays attack animation and sound when character is attacking
*/
watch(
() => props.mapCharacter.isAttacking,
(newValue) => {
if (newValue) {
playAnimation('attack')
playSound('/assets/sounds/attack.wav', false, true)
} else {
stopSound('/assets/sounds/attack.wav')
}
mapStore.updateCharacterProperty(props.mapCharacter.character.id, 'isAttacking', false)
}
)
/**
* Handles position updates and movement delay
*/
watch(
() => ({
positionX: props.mapCharacter.character.positionX,
@ -84,15 +51,14 @@ watch(
rotation: props.mapCharacter.character.rotation,
isAttacking: props.mapCharacter.isAttacking
}),
async (oldValues, newValues) => {
handlePositionUpdate(oldValues, newValues)
}
handlePositionUpdate
)
onMounted(async () => {
await initializeSprite()
if (props.mapCharacter.character.id === gameStore.character!.id) {
mapStore.setCharacterLoaded(true)
scene.cameras.main.startFollow(characterContainer.value as Phaser.GameObjects.Container)
}
})

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

View File

@ -1,5 +1,5 @@
<template>
<Container ref="characterChatContainer">
<Container ref="characterChatContainer" :depth="999">
<RoundRectangle @create="createChatBubble" :origin-x="0.5" :origin-y="7.5" :fillColor="0xffffff" :width="194" :height="21" :radius="20" />
<Text @create="createChatText" :style="{ fontSize: 13, fontFamily: 'Arial', color: '#000' }" />
</Container>

View File

@ -2,7 +2,7 @@
<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 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>
</div>
</div>
@ -21,8 +21,7 @@
</template>
<script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import { socketManager } from '@/managers/SocketManager'
import type { Chat } from '@/application/types'
import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore'
import { onClickOutside, useFocus } from '@vueuse/core'
@ -31,9 +30,10 @@ import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const scene = useScene()
const gameStore = useGameStore()
const mapStore = useMapStore()
const message = ref('')
const chats = ref<{ character: string; message: string }[]>([])
const chats = ref([] as Chat[])
const chatWindow = ref<HTMLElement | null>(null)
const chatInput = ref<HTMLElement | null>(null)
@ -55,7 +55,7 @@ function unfocusChat(event: Event, targetElement: HTMLElement) {
const sendMessage = () => {
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 = ''
}
@ -79,30 +79,21 @@ const scrollToBottom = () => {
})
}
socketManager.on(SocketEvent.CHAT_MESSAGE, (data: { character: string; message: string }) => {
if (!data.character || !data.message) return
chats.value.push({ character: data.character, message: data.message })
gameStore.connection?.on('chat:message', (data: Chat) => {
chats.value.push(data)
scrollToBottom()
const characterContainer = scene.children.getByName(data.character) as Phaser.GameObjects.Container
if (!characterContainer) {
console.log('No character container found')
return
}
if (!mapStore.characterLoaded) return
const characterChatContainer = characterContainer.getByName(data.character + '_chatContainer') as Phaser.GameObjects.Container
if (!characterChatContainer) {
console.log('No character chat container found')
return
}
const characterContainer = scene.children.getByName(data.character.name) as Phaser.GameObjects.Container
if (!characterContainer) return
const chatBubble = characterChatContainer.getByName(data.character + '_chatBubble') as Phaser.GameObjects.Container
const chatText = characterChatContainer.getByName(data.character + '_chatText') as Phaser.GameObjects.Text
if (!chatText || !chatBubble) {
console.log('No chat text or bubble found')
return
}
const characterChatContainer = characterContainer.getByName(data.character.name + '_chatContainer') as Phaser.GameObjects.Container
if (!characterChatContainer) return
const chatBubble = characterChatContainer.getByName(data.character.name + '_chatBubble') as Phaser.GameObjects.Container
const chatText = characterChatContainer.getByName(data.character.name + '_chatText') as Phaser.GameObjects.Text
if (!chatText || !chatBubble) return
function calculateTextWidth(text: string, font: string, fontSize: number): number {
// Create a canvas element
@ -153,7 +144,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
socketManager.off(SocketEvent.CHAT_MESSAGE)
gameStore.connection?.off('chat:message')
removeEventListener('keydown', focusChat)
})
</script>

View File

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

View File

@ -1,49 +1,14 @@
<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>
<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 { socketManager } from '@/managers/SocketManager'
import { useGameStore } from '@/stores/gameStore'
import { useMapStore } from '@/stores/mapStore'
import { onUnmounted } from 'vue'
const gameStore = useGameStore()
const mapStore = useMapStore()
const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap
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>

View File

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

View File

@ -1,32 +1,74 @@
<template>
<Controls v-if="tileMapLayer" :layer="tileMapLayer" :depth="0" />
<Controls v-if="tileLayer" :layer="tileLayer" :depth="0" />
</template>
<script setup lang="ts">
import config from '@/application/config'
import type { Map as MapT, UUID } from '@/application/types'
import { unduplicateArray } from '@/application/utilities'
import Controls from '@/components/utilities/Controls.vue'
import { loadTileTexturesFromMapTileArray, placeTiles } from '@/services/mapService'
import { loadMapTilesIntoScene, setLayerTiles } from '@/composables/mapComposable'
import { MapStorage } from '@/storage/storages'
import { useMapStore } from '@/stores/mapStore'
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 mapStore = useMapStore()
const mapStorage = new MapStorage()
const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap
tileMapLayer: Phaser.Tilemaps.TilemapLayer
}>()
const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
const tileLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
onMounted(async () => {
if (!mapStore.mapId) return
function createTileMap(map: MapT) {
const mapConfig = new Phaser.Tilemaps.MapData({
width: map.width,
height: map.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)
if (!map) return
const newTileMap = new Phaser.Tilemaps.Tilemap(scene, mapConfig)
emit('tileMap:create', newTileMap)
return newTileMap
}
await loadTileTexturesFromMapTileArray(mapStore.mapId, scene)
function createTileLayer(currentTileMap: Phaser.Tilemaps.Tilemap, mapData: any) {
const tilesArray = unduplicateArray(mapData?.tiles.flat())
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) => {
if (!mapData || !mapData?.tiles) return
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>

View File

@ -1,5 +1,5 @@
<template>
<PlacedMapObject v-for="placedMapObject in items" :tileMap :tileMapLayer :placedMapObject />
<PlacedMapObject v-for="placedMapObject in items" :tilemap="tilemap" :placedMapObject />
</template>
<script setup lang="ts">
@ -9,11 +9,8 @@ import { MapStorage } from '@/storage/storages'
import { useMapStore } from '@/stores/mapStore'
import { onMounted, ref } from 'vue'
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
defineProps<{
tileMap: Phaser.Tilemaps.Tilemap
tileMapLayer: TilemapLayer
tilemap: Phaser.Tilemaps.Tilemap
}>()
const mapStore = useMapStore()
@ -23,7 +20,7 @@ const items = ref<PlacedMapObjectT[]>([])
onMounted(async () => {
if (!mapStore.mapId) return
const map = await mapStorage.getById(mapStore.mapId)
const map = await mapStorage.get(mapStore.mapId)
if (!map) return
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>
<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>
<script setup lang="ts">
import type { MapObject, PlacedMapObject } from '@/application/types'
import ImageGroup from '@/components/game/map/partials/ImageGroup.vue'
import { loadMapObjectTextures, tileToWorldXY } from '@/services/mapService'
import { MapObjectStorage } from '@/storage/storages'
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 { useScene } from 'phavuer'
import { computed, onMounted, ref } from 'vue'
import Tilemap = Phaser.Tilemaps.Tilemap
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
import { Image, useScene } from 'phavuer'
import { computed, onMounted } from 'vue'
const props = defineProps<{
tilemap: Phaser.Tilemaps.Tilemap
placedMapObject: PlacedMapObject
tileMap: Tilemap
tileMapLayer: TilemapLayer
}>()
const gameStore = useGameStore()
const scene = useScene()
const gameStore = useGameStore()
const mapObject = ref<MapObject>()
const groupProps = computed(() => ({
...calculateObjectPlacement(props.placedMapObject),
mapObj: mapObject.value,
obj: props.placedMapObject
const imageProps = computed(() => ({
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)
}))
async function initialize() {
if (!props.placedMapObject.mapObject) return
/**
* Check if mapObject is an string or object, if its an object we assume its a mapObject and change it to a string
* We do this because this component is shared with the map editor, which gets sent the mapObject as an object by the server
*/
if (typeof props.placedMapObject.mapObject === 'object') {
// @ts-ignore
props.placedMapObject.mapObject = props.placedMapObject.mapObject.id
}
const mapObjectStorage = new MapObjectStorage()
const _mapObject = await mapObjectStorage.getById(props.placedMapObject.mapObject as string)
if (!_mapObject) return
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()
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)
})
onMounted(async () => {})
</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">Chats</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="$emit('open-map-editor')">Map editor</button>
</div>
</template>
<template #modalBody>
@ -24,8 +24,9 @@ import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { useGameStore } from '@/stores/gameStore'
import { ref } from 'vue'
const mapEditor = useMapEditorComposable()
defineEmits(['open-map-editor'])
const gameStore = useGameStore()
const mapEditor = useMapEditorComposable()
let toggle = ref('asset-manager')
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,9 +25,7 @@
<script setup lang="ts">
import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import type { Sprite } from '@/application/types'
import { socketManager } from '@/managers/SocketManager'
import { useAssetManagerStore } from '@/stores/assetManagerStore'
import { useGameStore } from '@/stores/gameStore'
import { useVirtualList } from '@vueuse/core'
@ -42,13 +40,13 @@ const hasScrolled = ref(false)
const elementToScroll = ref()
function newButtonClickHandler() {
socketManager.emit(SocketEvent.GM_SPRITE_CREATE, {}, (response: boolean) => {
gameStore.connection?.emit('gm:sprite:create', {}, (response: boolean) => {
if (!response) {
if (config.environment === 'development') console.error('Failed to create new sprite')
if (config.development) console.error('Failed to create new sprite')
return
}
socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
gameStore.connection?.emit('gm:sprite:list', {}, (response: Sprite[]) => {
assetManagerStore.setSpriteList(response)
})
})
@ -87,7 +85,7 @@ function toTop() {
}
onMounted(() => {
socketManager.emit(SocketEvent.GM_SPRITE_LIST, {}, (response: Sprite[]) => {
gameStore.connection?.emit('gm:sprite:list', {}, (response: Sprite[]) => {
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,185 @@
<template>
<div class="flex flex-wrap gap-3">
<div v-for="(image, index) in modelValue" :key="index" class="h-20 w-20 p-4 bg-gray-300 bg-opacity-50 rounded text-center relative group cursor-move" draggable="true" @dragstart="dragStart($event, index)" @dragover.prevent @dragenter.prevent @drop="drop($event, index)">
<img :src="image.url" class="max-w-full max-h-full object-contain pointer-events-none" alt="Uploaded image" @load="updateImageDimensions($event, index)" />
<div v-if="imageDimensions[index]" class="absolute bottom-1 right-1 bg-black/50 text-white text-xs px-1 py-0.5 rounded transition-opacity font-default">{{ image.dimensions.width }}x{{ image.dimensions.height }}</div>
<div class="absolute top-1 left-1 flex-row space-y-1">
<button @click.stop="deleteImage(index)" class="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity" aria-label="Delete image">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button @click.stop="openOffsetModal(index)" class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity" aria-label="Scope image">
<svg width="50px" height="50px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.29289 3.70711L1 11V15H5L12.2929 7.70711L8.29289 3.70711Z" fill="white" />
<path d="M9.70711 2.29289L13.7071 6.29289L15.1716 4.82843C15.702 4.29799 16 3.57857 16 2.82843C16 1.26633 14.7337 0 13.1716 0C12.4214 0 11.702 0.297995 11.1716 0.828428L9.70711 2.29289Z" fill="white" />
</svg>
</button>
</div>
<Modal :is-modal-open="selectedImageIndex === index" :modal-width="300" :modal-height="210" :is-resizable="false" :bg-style="'none'" @modal:close="closeOffsetModal">
<template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">Action offset ({{ selectedImageIndex }})</h3>
</template>
<template #modalBody>
<div class="m-4">
<form method="post" @submit.prevent="saveOffset(index)" class="inline">
<div class="gap-2.5 flex flex-wrap">
<div class="form-field-half">
<label for="offsetX">Offset X</label>
<input class="input-field max-w-64" v-model="tempOffset.x" name="offsetX" id="offsetX" type="number" />
</div>
<div class="form-field-half">
<label for="offsetY">Offset Y</label>
<input class="input-field max-w-64" v-model="tempOffset.y" name="offsetY" id="offsetY" type="number" />
</div>
</div>
<button class="btn-cyan px-4 py-1.5 min-w-24" type="submit">Save</button>
</form>
</div>
</template>
</Modal>
</div>
<div class="h-20 w-20 p-4 bg-gray-200 bg-opacity-50 rounded justify-center items-center flex hover:cursor-pointer" @click="triggerFileInput" @drop.prevent="onDrop" @dragover.prevent>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 invert" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
</div>
<input type="file" ref="fileInput" @change="onFileChange" multiple accept="image/png" class="hidden" />
</template>
<script setup lang="ts">
import Modal from '@/components/utilities/Modal.vue'
import { ref, watch } from 'vue'
interface SpriteImage {
url: string
offset: {
x: number
y: number
}
}
interface Props {
modelValue: SpriteImage[]
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => []
})
const emit = defineEmits<{
(e: 'update:modelValue', value: SpriteImage[]): void
(e: 'close'): void
(e: 'tempOffsetChange', index: number, offset: { x: number; y: number }): void
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const draggedIndex = ref<number | null>(null)
const selectedImageIndex = ref<number | null>(null)
const tempOffset = ref({ x: 0, y: 0 })
const triggerFileInput = () => {
fileInput.value?.click()
}
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files) {
handleFiles(target.files)
}
}
const onDrop = (event: DragEvent) => {
if (event.dataTransfer?.files) {
handleFiles(event.dataTransfer.files)
}
}
const handleFiles = (files: FileList) => {
Array.from(files).forEach((file) => {
if (!file.type.startsWith('image/')) {
return
}
const reader = new FileReader()
reader.onload = (e) => {
if (typeof e.target?.result === 'string') {
const newImage: SpriteImage = {
url: e.target.result,
offset: { x: 0, y: 0 }
}
updateImages([...props.modelValue, newImage])
}
}
reader.readAsDataURL(file)
})
}
const updateImages = (newImages: SpriteImage[]) => {
emit('update:modelValue', newImages)
}
const deleteImage = (index: number) => {
const newImages = [...props.modelValue]
newImages.splice(index, 1)
updateImages(newImages)
}
const dragStart = (event: DragEvent, index: number) => {
draggedIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.dropEffect = 'move'
}
}
const drop = (event: DragEvent, dropIndex: number) => {
event.preventDefault()
if (draggedIndex.value !== null && draggedIndex.value !== dropIndex) {
const newImages = [...props.modelValue]
const [reorderedItem] = newImages.splice(draggedIndex.value, 1)
newImages.splice(dropIndex, 0, reorderedItem)
updateImages(newImages)
}
draggedIndex.value = null
}
const openOffsetModal = (index: number) => {
selectedImageIndex.value = index
tempOffset.value = { ...props.modelValue[index].offset }
}
const closeOffsetModal = () => {
selectedImageIndex.value = null
}
const saveOffset = (index: number) => {
const newImages = [...props.modelValue]
newImages[index] = {
...newImages[index],
offset: { ...tempOffset.value }
}
updateImages(newImages)
closeOffsetModal()
}
const onOffsetChange = () => {
if (selectedImageIndex.value !== null) {
emit('tempOffsetChange', selectedImageIndex.value, tempOffset.value)
}
}
watch(tempOffset, onOffsetChange, { deep: true })
const imageDimensions = ref<{ [key: number]: { width: number; height: number } }>({})
const updateImageDimensions = (event: Event, index: number) => {
const img = event.target as HTMLImageElement
imageDimensions.value[index] = {
width: img.naturalWidth,
height: img.naturalHeight
}
}
</script>

View File

@ -0,0 +1,146 @@
<template>
<Modal :is-modal-open="isModalOpen" :modal-width="700" :modal-height="330" :bg-style="'none'" @modal:close="closeModal">
<template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">View sprite</h3>
</template>
<template #modalBody>
<div class="m-4 flex gap-8">
<div class="relative">
<div
class="sprite-container bg-gray-800"
:style="{
width: `${maxWidth}px`,
height: `${maxHeight}px`,
position: 'relative',
overflow: 'hidden'
}"
>
<img
v-for="(sprite, index) in spritesWithTempOffset"
:key="index"
:src="sprite.url"
alt="Sprite"
:style="{
position: 'absolute',
left: `${sprite.offset?.x || 0}px`,
bottom: `${sprite.offset?.y || 0}px`,
display: currentFrame === index ? 'block' : 'none',
transform: `scale(${zoomLevel / 100})`,
transformOrigin: 'bottom left'
}"
@load="updateContainerSize"
/>
</div>
</div>
<div class="flex flex-col justify-center gap-8 flex-1">
<div class="flex flex-col">
<label class="block mb-2 text-white">Frame Rate: {{ frameRate }} FPS</label>
<input type="range" v-model.number="localFrameRate" min="0" max="60" step="1" class="w-full accent-cyan-500" @input="updateFrameRate" />
</div>
<div class="flex flex-col">
<label class="block mb-2 text-white">Frame: {{ currentFrame + 1 }} of {{ sprites.length }}</label>
<input type="range" v-model.number="currentFrame" :min="0" :max="sprites.length - 1" step="1" class="w-full accent-cyan-500" @input="stopAnimation" />
</div>
<div class="flex flex-col">
<label class="block mb-2 text-white">Zoom: {{ zoomLevel }}%</label>
<input type="range" v-model.number="zoomLevel" min="10" max="200" step="10" class="w-full accent-cyan-500" />
</div>
</div>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import type { SpriteImage } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
sprites: SpriteImage[]
frameRate: number
isModalOpen?: boolean
tempOffsetIndex?: number
tempOffset?: { x: number; y: number }
}>()
const emit = defineEmits<{
(e: 'update:frameRate', value: number): void
(e: 'update:isModalOpen', value: boolean): void
}>()
const currentFrame = ref(0)
const maxWidth = ref(250)
const maxHeight = ref(250)
const localFrameRate = ref(props.frameRate)
const zoomLevel = ref(100)
let animationInterval: number | null = null
const spritesWithTempOffset = computed(() => {
return props.sprites.map((sprite, index) => {
if (index === props.tempOffsetIndex && props.tempOffset) {
return { ...sprite, offset: props.tempOffset }
}
return sprite
})
})
function updateContainerSize(event: Event) {
const img = event.target as HTMLImageElement
maxWidth.value = Math.max(maxWidth.value, img.naturalWidth)
maxHeight.value = Math.max(maxHeight.value, img.naturalHeight)
}
function updateAnimation() {
stopAnimation()
if (props.frameRate <= 0 || props.sprites.length === 0) {
currentFrame.value = 0
return
}
animationInterval = window.setInterval(() => {
currentFrame.value = (currentFrame.value + 1) % props.sprites.length
}, 1000 / props.frameRate)
}
function stopAnimation() {
if (animationInterval) {
clearInterval(animationInterval)
animationInterval = null
}
}
function updateFrameRate() {
emit('update:frameRate', localFrameRate.value)
}
function closeModal() {
emit('update:isModalOpen', false)
}
watch(
() => props.frameRate,
(newValue) => {
localFrameRate.value = newValue
updateAnimation()
},
{ immediate: true }
)
watch(() => props.sprites, updateAnimation, { immediate: true })
onMounted(() => {
updateAnimation()
})
onUnmounted(() => {
stopAnimation()
})
</script>
<style scoped>
.sprite-container {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
</style>

View File

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

View File

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

View File

@ -1,245 +1,46 @@
<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 />
<MapTiles ref="mapTiles" @tileMap:create="tileMap = $event" />
<PlacedMapObjects ref="mapObjects" v-if="tileMap" :tileMap="tileMap as Phaser.Tilemaps.Tilemap" />
<MapEventTiles ref="eventTiles" v-if="tileMap" :tileMap="tileMap as Phaser.Tilemaps.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'
import { onMounted, onUnmounted, shallowRef, useTemplateRef } 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) {
function handlePointer(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 shift is pressed or if we're in move mode, this means we are moving the camera
if (pointer.event.shiftKey || mapEditor.tool.value === 'move') return
// Check if draw mode is tile
switch (mapEditor.drawMode.value) {
case 'tile':
mapTiles.value.handlePointer(pointer)
break
case 'map_object':
case 'object':
mapObjects.value.handlePointer(pointer)
break
case 'teleport':
eventTiles.value.handlePointer(pointer)
break
case 'blocking tile':
case 'event':
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

@ -3,71 +3,22 @@
</template>
<script setup lang="ts">
import { MapEventTileType, type MapEventTile, type Map as MapT, type UUID } from '@/application/types'
import { MapEventTileType, type MapEventTile, type Map as MapT } from '@/application/types'
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 { cloneArray, getTile, tileToWorldX, tileToWorldY } from '@/services/mapService'
import { Image } from 'phavuer'
import { Image, useScene } from 'phavuer'
import { shallowRef } from 'vue'
const mapEditor = useMapEditorComposable()
defineExpose({ handlePointer, finalizeCommand, clearTiles })
const emit = defineEmits(['createCommand'])
defineExpose({ handlePointer })
const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap
}>()
// *** COMMAND STATE ***
let currentCommand: EventTileCommand | null = null
class EventTileCommand implements EditorCommand {
public operation: 'draw' | 'erase' | 'clear' = 'draw'
public type: 'event_tile' = 'event_tile'
public affectedTiles: MapEventTile[] = []
apply(elements: MapEventTile[]) {
let tileVersion = cloneArray(elements) as MapEventTile[]
if (this.operation === 'draw') {
tileVersion = tileVersion.concat(this.affectedTiles)
} else if (this.operation === 'erase') {
tileVersion = tileVersion.filter((v) => !this.affectedTiles.includes(v))
} else if (this.operation === 'clear') {
tileVersion = []
}
return tileVersion
}
constructor(operation: 'draw' | 'erase' | 'clear') {
this.operation = operation
}
}
function createCommandUpdate(tile?: MapEventTile | 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 ***
const tileLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
function getImageProps(tile: MapEventTile) {
return {
@ -79,8 +30,10 @@ function getImageProps(tile: MapEventTile) {
}
function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
if (!tileLayer.value) return
// Check if there is a tile
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
const tile = getTile(tileLayer.value, pointer.worldX, pointer.worldY)
if (!tile) return
// Check if event tile already exists on position
@ -88,17 +41,19 @@ function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
if (existingEventTile) return
// If teleport, check if there is a selected map
if (mapEditor.drawMode.value === 'teleport' && !mapEditor.teleportSettings.value.toMap) return
if (mapEditor.drawMode.value === 'teleport' && !mapEditor.teleportSettings.value.toMapId) return
const newEventTile = {
id: uuidv4() as UUID,
id: uuidv4(),
mapId: map?.id,
map: map?.id,
type: mapEditor.drawMode.value === 'blocking tile' ? MapEventTileType.BLOCK : MapEventTileType.TELEPORT,
positionX: tile.x,
positionY: tile.y,
teleport:
mapEditor.drawMode.value === 'teleport'
? {
toMap: mapEditor.teleportSettings.value.toMap,
toMap: mapEditor.teleportSettings.value.toMapId,
toPositionX: mapEditor.teleportSettings.value.toPositionX,
toPositionY: mapEditor.teleportSettings.value.toPositionY,
toRotation: mapEditor.teleportSettings.value.toRotation
@ -106,28 +61,19 @@ function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
: undefined
}
createCommandUpdate(newEventTile as MapEventTile, 'draw')
map.mapEventTiles.push(newEventTile as MapEventTile)
map!.mapEventTiles = map!.mapEventTiles.concat(newEventTile as MapEventTile)
}
function erase(pointer: Phaser.Input.Pointer, map: MapT) {
if (!tileLayer.value) return
// Check if there is a tile
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
const tile = getTile(tileLayer.value, pointer.worldX, pointer.worldY)
if (!tile) return
// Check if event tile already exists on position
const existingEventTile = map.mapEventTiles.find((eventTile) => eventTile.positionX === tile.x && eventTile.positionY === tile.y)
if (!existingEventTile) return
if (mapEditor.drawMode.value !== existingEventTile.type.toLowerCase()) {
if (mapEditor.drawMode.value === 'blocking tile' && existingEventTile.type === MapEventTileType.BLOCK)
null //skip this case
else return
}
createCommandUpdate(existingEventTile, 'erase')
// Remove existing event tile
map.mapEventTiles = map.mapEventTiles.filter((eventTile) => eventTile.id !== existingEventTile.id)
}
@ -136,7 +82,11 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
const map = mapEditor.currentMap.value
if (!map) return
if (pointer.event.altKey) 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
switch (mapEditor.tool.value) {
case 'pencil':
@ -147,10 +97,4 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
break
}
}
function clearTiles() {
if (mapEditor.currentMap.value?.mapEventTiles.length === 0) return
createCommandUpdate(null, 'clear')
finalizeCommand()
}
</script>

View File

@ -1,102 +1,109 @@
<template>
<Controls v-if="tileMapLayer" :layer="tileMapLayer" :depth="0" />
<Controls v-if="tileLayer" :layer="tileLayer" :depth="0" />
</template>
<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 { createTileArray, getTile, placeTile, setLayerTiles } from '@/composables/mapComposable'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { cloneArray, createTileArray, getTile, placeTile, placeTiles } from '@/services/mapService'
import { onMounted, ref, watch } from 'vue'
import { TileStorage } from '@/storage/storages'
import { useScene } from 'phavuer'
import { onMounted, onUnmounted, shallowRef, watch } from 'vue'
import Tileset = Phaser.Tilemaps.Tileset
const emit = defineEmits(['tileMap:create'])
const scene = useScene()
const mapEditor = useMapEditorComposable()
const tileStorage = new TileStorage()
defineExpose({ handlePointer, finalizeCommand, clearTiles })
const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
const tileLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
const emit = defineEmits(['createCommand'])
defineExpose({ handlePointer })
const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap
tileMapLayer: Phaser.Tilemaps.TilemapLayer
}>()
function createTileMap() {
const mapData = new Phaser.Tilemaps.MapData({
width: mapEditor.currentMap.value?.width,
height: mapEditor.currentMap.value?.height,
tileWidth: config.tile_size.width,
tileHeight: config.tile_size.height,
orientation: Phaser.Tilemaps.Orientation.ISOMETRIC,
format: Phaser.Tilemaps.Formats.ARRAY_2D
})
// *** COMMAND STATE ***
let currentCommand: TileCommand | null = null
class TileCommand implements EditorCommand {
public operation: 'draw' | 'erase' | 'clear' = 'draw'
public type: 'tile' = 'tile'
public tileName: string = 'blank_tile'
public affectedTiles: number[][] = []
apply(elements: string[][]) {
let tileVersion
if (this.operation === 'clear') {
tileVersion = createTileArray(props.tileMapLayer.width, props.tileMapLayer.height, 'blank_tile')
} else {
tileVersion = cloneArray(elements) as string[][]
for (const position of this.affectedTiles) {
tileVersion[position[1]][position[0]] = this.tileName
}
}
return tileVersion
const newTileMap = new Phaser.Tilemaps.Tilemap(scene, mapData)
emit('tileMap:create', newTileMap)
return newTileMap
}
constructor(operation: 'draw' | 'erase' | 'clear', tileName: string) {
this.operation = operation
this.tileName = tileName
}
async function createTileLayer(currentTileMap: Phaser.Tilemaps.Tilemap) {
const tiles = await tileStorage.getAll()
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 }))
}
function createCommandUpdate(x: number, y: number, tileName: string, operation: 'draw' | 'erase' | 'clear') {
if (!currentCommand) {
currentCommand = new TileCommand(operation, tileName)
// 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
}
//If position is already in, do not proceed
for (const vec of currentCommand.affectedTiles) {
if (vec[0] === x && vec[1] === y) return
}
currentCommand.affectedTiles.push([x, y])
}
function finalizeCommand() {
if (!currentCommand) return
emit('createCommand', currentCommand)
currentCommand = null
}
// *** HANDLERS ***
function draw(pointer: Phaser.Input.Pointer, tileName: string) {
function pencil(pointer: Phaser.Input.Pointer) {
let map = mapEditor.currentMap.value
if (!map) return
// Check if there is a selected tile
if (!mapEditor.selectedTile.value) return
if (!tileMap.value || !tileLayer.value) return
// 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
// Place tile
placeTile(props.tileMap, props.tileMapLayer, tile.x, tile.y, tileName)
createCommandUpdate(tile.x, tile.y, tileName, tileName === 'blank_tile' ? 'erase' : 'draw')
placeTile(tileMap.value, tileLayer.value, tile.x, tile.y, mapEditor.selectedTile.value)
// Adjust mapEditorStore.map.tiles
map.tiles[tile.y][tile.x] = tileName
map.tiles[tile.y][tile.x] = mapEditor.selectedTile.value
}
function paint(pointer: Phaser.Input.Pointer) {
function eraser(pointer: Phaser.Input.Pointer) {
let map = mapEditor.currentMap.value
if (!map) return
// Set new tileArray with selected tile
const tileArray = createTileArray(props.tileMap.width, props.tileMap.height, mapEditor.selectedTile.value)
placeTiles(props.tileMap, props.tileMapLayer, tileArray)
if (!tileMap.value || !tileLayer.value) 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
map.tiles = tileArray
map.tiles[tile.y][tile.x] = 'blank_tile'
}
function paint(pointer: Phaser.Input.Pointer) {
if (!tileMap.value || !tileLayer.value) return
// Set new tileArray with selected tile
const tileArray = createTileArray(tileMap.value.width, tileMap.value.height, mapEditor.selectedTile.value)
setLayerTiles(tileMap.value, tileLayer.value, tileArray)
// Adjust mapEditorStore.map.tiles
if (mapEditor.currentMap.value) {
mapEditor.currentMap.value.tiles = tileArray
}
}
// When alt is pressed, and the pointer is down, select the tile that the pointer is over
@ -104,17 +111,33 @@ function tilePicker(pointer: Phaser.Input.Pointer) {
let map = mapEditor.currentMap.value
if (!map) return
if (!tileMap.value || !tileLayer.value) return
// 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
// Select the tile
mapEditor.setSelectedTile(map.tiles[tile.y][tile.x])
}
watch(
() => mapEditor.shouldClearTiles,
(shouldClear) => {
if (shouldClear && mapEditor.currentMap.value && tileMap.value && tileLayer.value) {
const blankTiles = createTileArray(tileLayer.value.width, tileLayer.value.height, 'blank_tile')
setLayerTiles(tileMap.value, tileLayer.value, blankTiles)
mapEditor.currentMap.value.tiles = blankTiles
mapEditor.resetClearTilesFlag()
}
}
)
function handlePointer(pointer: Phaser.Input.Pointer) {
if (!tileMap.value || !tileLayer.value) return
// 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
if (pointer.event.shiftKey) return
@ -128,10 +151,10 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
// Check if draw mode is tile
switch (mapEditor.tool.value) {
case 'pencil':
draw(pointer, mapEditor.selectedTile.value!)
pencil(pointer)
break
case 'eraser':
draw(pointer, 'blank_tile')
eraser(pointer)
break
case 'paint':
paint(pointer)
@ -139,19 +162,33 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
}
}
// *** LIFECYCLE ***
function clearTiles() {
const tileArray = createTileArray(props.tileMap.width, props.tileMap.height, 'blank_tile')
placeTiles(props.tileMap, props.tileMapLayer, tileArray)
createCommandUpdate(0, 0, 'blank_tile', 'clear')
finalizeCommand()
}
onMounted(async () => {
if (!mapEditor.currentMap.value) return
const mapState = mapEditor.currentMap.value
placeTiles(props.tileMap, props.tileMapLayer, mapState.tiles)
tileMap.value = createTileMap()
tileLayer.value = await createTileLayer(tileMap.value)
// First fill the entire map with blank tiles using current map dimensions
const blankTiles = createTileArray(mapEditor.currentMap.value.width, mapEditor.currentMap.value.height, 'blank_tile')
// Then overlay the map tiles, but only within the current map dimensions
const mapTiles = mapEditor.currentMap.value.tiles
for (let y = 0; y < mapEditor.currentMap.value.height; y++) {
for (let x = 0; x < mapEditor.currentMap.value.width; x++) {
if (mapTiles[y] && mapTiles[y][x] !== undefined) {
blankTiles[y][x] = mapTiles[y][x]
}
}
}
setLayerTiles(tileMap.value, tileLayer.value, blankTiles)
})
onUnmounted(() => {
if (tileMap.value) {
tileMap.value.destroyLayer('tiles')
tileMap.value.removeAllLayers()
tileMap.value.destroy()
}
})
</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,173 +1,112 @@
<template>
<PlacedMapObject
v-if="mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'map_object' && mapEditor.isPlacedMapObjectPreviewEnabled.value && mapEditor.selectedMapObject.value && previewPlacedMapObject"
:tileMap
:tileMapLayer
:key="previewPlacedMapObject?.id"
:placedMapObject="previewPlacedMapObject as PlacedMapObjectT"
/>
<SelectedPlacedMapObjectComponent v-if="mapEditor.selectedPlacedObject.value" :key="mapEditor.selectedPlacedObject.value.id" :map :placedMapObject="mapEditor.selectedPlacedObject.value" @move="moveMapObject" @rotate="rotatePlacedMapObject" @delete="deletePlacedMapObject" />
<PlacedMapObject v-for="placedMapObject in mapEditor.currentMap.value?.placedMapObjects" :tileMap :tileMapLayer :placedMapObject @pointerdown="clickPlacedMapObject(placedMapObject)" :key="`${placedMapObject.id}-${placedMapObjectKey}`" />
<SelectedPlacedMapObjectComponent v-if="selectedPlacedMapObject" :placedMapObject="selectedPlacedMapObject" @move="moveMapObject" @rotate="rotatePlacedMapObject" @delete="deletePlacedMapObject" />
<PlacedMapObject v-for="placedMapObject in mapEditor.currentMap.value?.placedMapObjects" :tileMap="tileMap" :placedMapObject :selectedPlacedMapObject :movingPlacedMapObject @pointerup="clickPlacedMapObject(placedMapObject)" />
</template>
<script setup lang="ts">
import type { MapObject, Map as MapT, PlacedMapObject as PlacedMapObjectT } from '@/application/types'
import type { Map as MapT, PlacedMapObject as PlacedMapObjectT } from '@/application/types'
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 { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { getTile } from '@/services/mapService'
import { useScene } from 'phavuer'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Tilemap = Phaser.Tilemaps.Tilemap
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
import { ref, watch } from 'vue'
const scene = useScene()
const mapEditor = useMapEditorComposable()
const map = computed(() => mapEditor.currentMap.value!)
const placedMapObjectKey = computed(() => mapEditor.refreshMapObject.value)
const selectedPlacedMapObject = ref<PlacedMapObjectT | null>(null)
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 }>()
const props = defineProps<{
tileMap: Phaser.Tilemaps.Tilemap
}>()
defineExpose({ handlePointer })
const props = defineProps<{
tileMap: Tilemap
tileMapLayer: TilemapLayer
}>()
const previewPosition = ref({ x: 0, y: 0 })
const previewPlacedMapObject = computed(() => ({
id: mapEditor.selectedMapObject.value!.id,
mapObject: mapEditor.selectedMapObject.value!.id,
isRotated: false,
positionX: previewPosition.value.x,
positionY: previewPosition.value.y
}))
function updatePreviewPosition(pointer: Phaser.Input.Pointer) {
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
if (!tile || (previewPosition.value.x === tile.x && previewPosition.value.y === tile.y)) return
previewPosition.value = { x: tile.x, y: tile.y }
}
function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
emit('pauseObjectTracking')
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
if (!tile) return
// Check if object already exists on position
const existingPlacedMapObject = findObjectByPointer(pointer, mapEditor.currentMap.value!)
const existingPlacedMapObject = findInMap(pointer, map)
if (existingPlacedMapObject) return
if (!mapEditor.selectedMapObject.value) return
const newPlacedMapObject: PlacedMapObjectT = {
id: uuidv4(),
mapObject: mapEditor.selectedMapObject.value.id,
depth: 0,
map: map,
mapObject: mapEditor.selectedMapObject.value!,
isRotated: false,
positionX: tile.x,
positionY: tile.y
positionX: pointer.x,
positionY: pointer.y
}
// Add new object to mapObjects
mapEditor.selectedPlacedObject.value = newPlacedMapObject
map.placedMapObjects.push(newPlacedMapObject)
emit('update', map)
map.placedMapObjects.concat(newPlacedMapObject)
}
function eraser(pointer: Phaser.Input.Pointer, map: MapT) {
emit('pauseObjectTracking')
// Check if object already exists on position
const existingPlacedMapObject = findObjectByPointer(pointer, map)
const existingPlacedMapObject = findInMap(pointer, map)
if (!existingPlacedMapObject) return
// Remove existing object
map.placedMapObjects = map.placedMapObjects.filter((placedMapObject) => placedMapObject.id !== existingPlacedMapObject.id)
emit('update', map)
}
function findObjectByPointer(pointer: Phaser.Input.Pointer, map: MapT): PlacedMapObjectT | undefined {
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
if (!tile) return undefined
return map.placedMapObjects.find((placedMapObject) => placedMapObject.positionX === tile.x && placedMapObject.positionY === tile.y)
function findInMap(pointer: Phaser.Input.Pointer, map: MapT) {
return map.placedMapObjects.find((placedMapObject) => placedMapObject.positionX === pointer.worldX && placedMapObject.positionY === pointer.worldY)
}
function objectPicker(pointer: Phaser.Input.Pointer, map: MapT) {
// Check if object already exists on position
const existingPlacedMapObject = findObjectByPointer(pointer, map)
const existingPlacedMapObject = findInMap(pointer, map)
if (!existingPlacedMapObject) return
// Select the object
mapEditor.setSelectedMapObject(existingPlacedMapObject.mapObject as MapObject)
mapEditor.setSelectedMapObject(existingPlacedMapObject.mapObject)
}
function moveMapObject(id: string, map: MapT) {
mapEditor.movingPlacedObject.value = map.placedMapObjects.find((object) => object.id === id) as PlacedMapObjectT
emit('pauseObjectTracking')
movingPlacedMapObject.value = map.placedMapObjects.find((object) => object.id === id) as PlacedMapObjectT
function handlePointerMove(pointer: Phaser.Input.Pointer) {
if (!mapEditor.movingPlacedObject.value) return
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
if (!tile) return
if (!movingPlacedMapObject.value) return
mapEditor.movingPlacedObject.value.positionX = tile.x
mapEditor.movingPlacedObject.value.positionY = tile.y
movingPlacedMapObject.value.positionX = pointer.worldX
movingPlacedMapObject.value.positionY = pointer.worldY
}
scene.input.on(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
scene.input.on(Phaser.Input.Events.POINTER_UP, handlePointerUp)
function handlePointerUp(pointer: Phaser.Input.Pointer) {
function handlePointerUp() {
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
movingPlacedMapObject.value = null
}
})
mapEditor.movingPlacedObject.value = null
scene.input.off(Phaser.Input.Events.POINTER_UP, handlePointerUp)
emit('resumeObjectTracking')
emit('updateAndCommit', map)
}
scene.input.on(Phaser.Input.Events.POINTER_UP, handlePointerUp)
}
function rotatePlacedMapObject(id: string, map: MapT) {
map.placedMapObjects.map((placed) => {
if (placed.id === id) {
placed.isRotated = !placed.isRotated
map.placedMapObjects = map.placedMapObjects.map((placedMapObject) => {
if (placedMapObject.id === id) {
return {
...placedMapObject,
isRotated: !placedMapObject.isRotated
}
}
return placedMapObject
})
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)
selectedPlacedMapObject.value = null
}
function clickPlacedMapObject(placedMapObject: PlacedMapObjectT) {
mapEditor.selectedPlacedObject.value = placedMapObject
selectedPlacedMapObject.value = placedMapObject
// If alt is pressed, select the object
if (scene.input.activePointer.event.altKey) {
mapEditor.setSelectedMapObject(placedMapObject.mapObject as MapObject)
mapEditor.setSelectedMapObject(placedMapObject.mapObject)
}
}
@ -175,13 +114,21 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
const map = mapEditor.currentMap.value
if (!map) return
if (mapEditor.drawMode.value !== 'map_object') 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, this means we are selecting the object
if (pointer.event.altKey) return
// Check if tool is pencil
switch (mapEditor.tool.value) {
case 'pencil':
pencil(pointer, map)
if (mapEditor.selectedMapObject.value) pencil(pointer, map)
break
case 'eraser':
eraser(pointer, map)
@ -192,11 +139,43 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
}
}
onMounted(() => {
scene.input.on(Phaser.Input.Events.POINTER_MOVE, updatePreviewPosition)
// watch mapEditorStore.mapObjectList and update originX and originY of objects in mapObjects
watch(
() => mapEditor.currentMap.value,
() => {
const map = mapEditor.currentMap.value
if (!map) return
const updatedMapObjects = map.placedMapObjects.map((mapObject) => {
const updatedMapObject = map.placedMapObjects.find((obj) => obj.id === mapObject.mapObject.id)
if (updatedMapObject) {
return {
...mapObject,
mapObject: {
...mapObject.mapObject,
originX: updatedMapObject.positionX,
originY: updatedMapObject.positionY
}
}
}
return mapObject
})
onUnmounted(() => {
scene.input.off(Phaser.Input.Events.POINTER_MOVE, updatePreviewPosition)
// Update the map with the new mapObjects
map.placedMapObjects = [...map.placedMapObjects, ...updatedMapObjects]
// Update mapObject if it's set
if (mapEditor.selectedMapObject.value) {
const updatedMapObject = map.placedMapObjects.find((obj) => obj.id === mapEditor.selectedMapObject.value?.id)
if (updatedMapObject) {
mapEditor.setSelectedMapObject({
...mapEditor.selectedMapObject.value,
originX: updatedMapObject.positionX,
originY: updatedMapObject.positionY
})
}
}
}
// { deep: true }
)
</script>

View File

@ -1,5 +1,5 @@
<template>
<Modal ref="modalRef" :modal-width="300" :modal-height="420" :is-resizable="false" bg-style="none">
<Modal ref="modalRef" :modal-width="300" :modal-height="420" :is-resizable="false" :bg-style="'none'">
<template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">Create new map</h3>
</template>
@ -35,11 +35,9 @@
</template>
<script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import type { Map } from '@/application/types'
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 { ref, useTemplateRef } from 'vue'
@ -58,8 +56,12 @@ const pvp = ref(false)
defineExpose({ open: () => modalRef.value?.open() })
async function submit() {
socketManager.emit(SocketEvent.GM_MAP_CREATE, { name: name.value, width: width.value, height: height.value }, async (response: Map | false) => {
gameStore.connection?.emit('gm:map:create', { name: name.value, width: width.value, height: height.value }, async (response: Map | false) => {
if (!response) {
gameStore.addNotification({
title: 'Error',
message: 'Failed to create map.'
})
return
}
@ -70,6 +72,8 @@ async function submit() {
pvp.value = false
// Add map to storage
console.log(response)
await mapStorage.add(response)
// Let list know to fetch new maps

View File

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

View File

@ -1,24 +1,25 @@
<template>
<div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'map_object'">
<div class="flex flex-col gap-2.5 p-2.5">
<div class="flex justify-between items-center">
<div class="flex-grow">
<div class="relative flex">
<img src="/assets/icons/mapEditor/search.svg" class="w-4 h-4 py-0.5 absolute top-1/2 -translate-y-1/2 left-2.5" alt="Search icon" />
<label class="mb-1.5 font-titles hidden" for="search">Search</label>
<input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" />
<div 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 z-20" v-if="isOpen">
<div class="relative z-10 p-2.5 border-solid border-0 border-b border-gray-500">
<h3 class="text-lg text-white">Map objects</h3>
</div>
<div class="overflow-hidden grow relative">
<div class="absolute w-full h-full top-0 left-0">
<div class="relative z-10 h-full">
<div class="flex pt-4 pl-4">
<div class="w-full flex gap-1.5 flex-row">
<div>
<label class="mb-1.5 font-titles hidden" for="search">Search...</label>
<input @mousedown.stop class="input-field" type="text" name="search" placeholder="Search" v-model="searchQuery" />
</div>
</div>
<img src="/assets/icons/mapEditor/dropdown-chevron.svg" class="w-12 h-12 ml-2 cursor-pointer hover:opacity-80 -rotate-90" alt="Close" @click="mapEditor.setTool('move')" />
</div>
<div class="flex">
<select class="input-field w-full" name="lists" v-model="mapEditor.drawMode.value" @change="(event: any) => mapEditor.setDrawMode(event.target.value)">
<option value="tile">Tiles</option>
<option value="map_object">Objects</option>
</select>
<div class="flex flex-col h-[calc(100%_-_170px)] p-4 pb-24">
<div class="mb-4 flex flex-wrap gap-2">
<button v-for="tag in uniqueTags" :key="tag" @click="toggleTag(tag)" class="btn-cyan" :class="{ 'opacity-50': !selectedTags.includes(tag) }">
{{ tag }}
</button>
</div>
</div>
<div class="h-full overflow-auto relative border-0 border-t border-solid border-gray-500 p-2.5">
<div class="h-full overflow-auto">
<div class="flex justify-between flex-wrap gap-2.5 items-center">
<div v-for="(mapObject, index) in filteredMapObjects" :key="index" class="max-w-1/4 inline-block">
@ -37,10 +38,7 @@
</div>
</div>
</div>
<div class="flex flex-col h-40 gap-2.5 p-3.5 border-t border-0 border-solid border-gray-500">
<span>Tags:</span>
<div class="flex grow items-center flex-wrap gap-1.5 overflow-auto">
<span class="m-auto">No tags selected</span>
</div>
</div>
</div>
</div>
@ -54,6 +52,13 @@ import { MapObjectStorage } from '@/storage/storages'
import { liveQuery } from 'dexie'
import { computed, onMounted, onUnmounted, ref } from 'vue'
defineExpose({
open: () => (isOpen.value = true),
close: () => (isOpen.value = false),
toggle: () => (isOpen.value = !isOpen.value)
})
const isOpen = ref(false)
const mapObjectStorage = new MapObjectStorage()
const mapEditor = useMapEditorComposable()
const searchQuery = ref('')
@ -65,14 +70,6 @@ const uniqueTags = computed(() => {
return Array.from(new Set(allTags))
})
const toggleTag = (tag: string) => {
if (selectedTags.value.includes(tag)) {
selectedTags.value = selectedTags.value.filter((t) => t !== tag)
} else {
selectedTags.value.push(tag)
}
}
const filteredMapObjects = computed(() => {
return mapObjectList.value.filter((object) => {
const matchesSearch = !searchQuery.value || object.name.toLowerCase().includes(searchQuery.value.toLowerCase())
@ -81,6 +78,14 @@ const filteredMapObjects = computed(() => {
})
})
const toggleTag = (tag: string) => {
if (selectedTags.value.includes(tag)) {
selectedTags.value = selectedTags.value.filter((t) => t !== tag)
} else {
selectedTags.value.push(tag)
}
}
let subscription: any = null
onMounted(() => {
@ -89,13 +94,14 @@ onMounted(() => {
mapObjectList.value = result
},
error: (error) => {
console.error('Failed to fetch tiles:', error)
console.error('Failed to fetch objects:', error)
}
})
})
onUnmounted(() => {
if (!subscription) return
if (subscription) {
subscription.unsubscribe()
}
})
</script>

View File

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

View File

@ -1,115 +1,33 @@
<template>
<div class="flex flex-col items-center px-5 py-1 fixed bottom-20 left-0 z-20">
<div class="flex h-10 gap-2">
<button @mousedown.stop @click="handleDelete" class="btn-red !py-3 px-4">
<div class="flex flex-col items-center py-5 px-3 fixed bottom-14 right-0">
<div class="self-end mt-2 flex gap-2">
<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" />
</button>
<button @mousedown.stop @click="showMapObjectSettings = !showMapObjectSettings" class="btn-indigo !py-3 px-4">
<img src="/assets/icons/mapEditor/gear.svg" class="w-4 h-4 invert" alt="Delete" />
</button>
<button @mousedown.stop @click="handleRotate" class="btn-cyan py-1.5 px-4">Rotate</button>
<button @mousedown.stop @click="handleMove" class="btn-cyan py-1.5 px-4 min-w-24">Move</button>
</div>
</div>
<Modal :is-modal-open="showMapObjectSettings" @modal:close="showMapObjectSettings = false" :modal-height="320" bg-style="none">
<template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">Map object settings</h3>
</template>
<template #modalBody>
<div class="m-4">
<form method="post" @submit.prevent="" class="inline">
<div class="gap-2.5 flex flex-wrap">
<div class="form-field-full">
<label for="name">Name</label>
<input class="input-field" v-model="mapObjectName" name="name" id="name" />
</div>
<div class="form-field-half">
<label for="originX">Origin X</label>
<input class="input-field" v-model="mapObjectOriginX" name="originX" id="originX" type="number" min="0.0" step="0.01" />
</div>
<div class="form-field-half">
<label for="originY">Origin Y</label>
<input class="input-field" v-model="mapObjectOriginY" name="originY" id="originY" type="number" min="0.0" step="0.01" />
</div>
</div>
<button class="btn-cyan px-4 py-1.5 min-w-24" @click="handleUpdate">Save</button>
</form>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import { SocketEvent } from '@/application/enums'
import type { MapObject, Map as MapT, PlacedMapObject } from '@/application/types'
import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { socketManager } from '@/managers/SocketManager'
import { MapObjectStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { onMounted, ref } from 'vue'
import type { PlacedMapObject } from '@/application/types'
const props = defineProps<{
placedMapObject: PlacedMapObject
map: MapT
}>()
const emit = defineEmits(['move', 'rotate', 'delete'])
const gameStore = useGameStore()
const mapEditor = useMapEditorComposable()
const mapObjectStorage = new MapObjectStorage()
const mapObject = ref<MapObject | null>(null)
const showMapObjectSettings = ref(false)
const mapObjectName = ref('')
const mapObjectOriginX = ref(0)
const mapObjectOriginY = ref(0)
const handleMove = () => {
emit('move', props.placedMapObject.id, props.map)
emit('move', props.placedMapObject.id)
}
const handleRotate = () => {
emit('rotate', props.placedMapObject.id, props.map)
emit('rotate', props.placedMapObject.id)
}
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>

View File

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

View File

@ -1,25 +1,27 @@
<template>
<div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="(mapEditor.tool.value === 'pencil' && mapEditor.drawMode.value === 'tile') || mapEditor.tool.value === 'paint'">
<div class="flex flex-col gap-2.5 p-2.5">
<div class="flex justify-between items-center">
<div class="flex-grow">
<div class="relative flex">
<img src="/assets/icons/mapEditor/search.svg" class="w-4 h-4 py-0.5 absolute top-1/2 -translate-y-1/2 left-2.5" alt="Search icon" />
<label class="mb-1.5 font-titles hidden" for="search">Search</label>
<input @mousedown.stop class="!pl-7 input-field w-full" type="text" name="search" placeholder="Search" v-model="searchQuery" />
<div 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 z-20" v-if="isOpen">
<div class="relative z-10 p-2.5 border-solid border-0 border-b border-gray-500">
<h3 class="text-lg text-white">Tiles</h3>
</div>
</div>
<img src="/assets/icons/mapEditor/dropdown-chevron.svg" class="w-12 h-12 ml-2 cursor-pointer hover:opacity-80 -rotate-90" alt="Close" @click="mapEditor.setTool('move')" />
</div>
<div class="flex">
<select class="input-field w-full" name="lists" v-model="mapEditor.drawMode.value" @change="(event: any) => mapEditor.setDrawMode(event.target.value)">
<option value="tile">Tiles</option>
<option value="map_object">Objects</option>
</select>
</div>
</div>
<div class="h-full overflow-auto relative border-0 border-t border-solid border-gray-500 p-2.5">
<div class="overflow-hidden grow relative">
<div class="absolute top-0 left-0 h-full w-full">
<div class="relative z-10 h-full">
<div class="h-full" v-if="!selectedGroup">
<div class="flex pt-4 pl-4">
<div class="w-full flex gap-1.5 flex-row">
<div>
<label class="mb-1.5 font-titles hidden" for="search">Search...</label>
<input @mousedown.stop class="input-field" type="text" name="search" placeholder="Search" v-model="searchQuery" />
</div>
</div>
</div>
<div class="flex flex-col h-[calc(100%_-_170px)] p-4 pb-24">
<div class="mb-4 flex flex-wrap gap-2">
<button v-for="tag in uniqueTags" :key="tag" @click="toggleTag(tag)" class="btn-cyan" :class="{ 'opacity-50': !selectedTags.includes(tag) }">
{{ tag }}
</button>
</div>
<div class="h-full flex-grow overflow-y-auto">
<div class="grid grid-cols-4 gap-2 justify-items-center">
<div v-for="group in groupedTiles" :key="group.parent.id" class="flex flex-col items-center justify-center relative">
<img
@ -40,11 +42,12 @@
</div>
</div>
</div>
</div>
</div>
<div v-else class="h-full overflow-auto">
<div class="p-4">
<div class="text-center mb-8">
<button @click="closeGroup" class="hover:text-white">Back to all tiles</button>
</div>
<button @click="closeGroup" class="btn-cyan mb-4">Back to All Tiles</button>
<h4 class="text-lg mb-4">{{ selectedGroup.parent.name }} Group</h4>
<div class="grid grid-cols-4 gap-2 justify-items-center">
<div class="flex flex-col items-center justify-center">
<img
@ -76,10 +79,6 @@
</div>
</div>
</div>
<div class="flex flex-col h-40 gap-2.5 p-3.5 border-t border-0 border-solid border-gray-500">
<span>Tags:</span>
<div class="flex grow items-center flex-wrap gap-1.5 overflow-auto">
<span class="m-auto">No tags selected</span>
</div>
</div>
</div>
@ -93,6 +92,7 @@ import { useTileProcessingComposable } from '@/composables/useTileProcessingComp
import { TileStorage } from '@/storage/storages'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const isOpen = ref(false)
const tileStorage = new TileStorage()
const mapEditor = useMapEditorComposable()
const tileProcessor = useTileProcessingComposable()
@ -102,6 +102,12 @@ const tileCategories = ref<Map<string, string>>(new Map())
const selectedGroup = ref<{ parent: Tile; children: Tile[] } | null>(null)
const tiles = ref<Tile[]>([])
defineExpose({
open: () => (isOpen.value = true),
close: () => (isOpen.value = false),
toggle: () => (isOpen.value = !isOpen.value)
})
const uniqueTags = computed(() => {
const allTags = tiles.value.flatMap((tile) => tile.tags || [])
return Array.from(new Set(allTags))

View File

@ -1,6 +1,6 @@
<template>
<div class="flex justify-between p-5 w-[calc(100%_-_40px)] fixed bottom-0 left-0 z-20" :class="{ 'list-open': listOpen }">
<div class="toolbar rounded flex bg-gray solid border-solid border-2 border-gray-500 text-gray-300 p-1.5 px-3 h-10">
<div class="flex justify-center p-5">
<div class="toolbar fixed bottom-0 left-0 m-3 rounded flex bg-gray solid border-solid border-2 border-gray-500 text-gray-300 p-1.5 px-3 h-10 z-20">
<div ref="toolbar" class="tools flex gap-2.5" v-if="mapEditor.currentMap.value">
<button class="flex justify-center items-center min-w-10 p-0 relative" :class="{ 'border-0 border-b-[3px] border-solid border-cyan gap-2.5': mapEditor.tool.value === 'move' }" @click="handleClick('move')">
<img class="invert w-5 h-5" src="/assets/icons/mapEditor/move.svg" alt="Move camera" /> <span class="h-5" :class="{ 'ml-2.5': mapEditor.tool.value !== 'move' }">(M)</span>
@ -68,59 +68,53 @@
<div class="w-px bg-cyan"></div>
<button class="flex justify-center items-center min-w-10 p-0 relative" @click="isMapEditorSettingsModalOpen = !isMapEditorSettingsModalOpen"><img class="invert w-5 h-5" src="/assets/icons/mapEditor/gear.svg" alt="Map settings" /> <span class="h-5 ml-2.5">(Z)</span></button>
<button class="flex justify-center items-center min-w-10 p-0 relative" @click="handleClick('settings')"><img class="invert w-5 h-5" src="/assets/icons/mapEditor/gear.svg" alt="Map settings" /> <span class="h-5 ml-2.5">(Z)</span></button>
<div class="w-px bg-cyan"></div>
<button class="flex justify-center items-center min-w-10 p-0 relative" @click="isMapEditorSettingsModalOpen = !isMapEditorSettingsModalOpen"><img class="invert w-5 h-5" src="/assets/icons/mapEditor/settings.svg" alt="Map settings" /> <span class="h-5 ml-2.5">(S)</span></button>
<label class="my-auto gap-0" for="checkbox">Continuous Drawing</label>
<input type="checkbox" />
</div>
</div>
<div class="toolbar rounded flex bg-gray solid border-solid border-2 border-gray-500 text-gray-300 p-1.5 px-3 h-10 space-x-2">
<div class="toolbar fixed bottom-0 right-0 m-3 rounded flex bg-gray solid border-solid border-2 border-gray-500 text-gray-300 p-1.5 px-3 h-10 space-x-2">
<button class="btn-cyan px-3.5" @click="() => emit('open-maps')">Load</button>
<button class="btn-cyan px-3.5" @click="() => emit('save')" v-if="mapEditor.currentMap.value">Save</button>
<button class="btn-cyan px-3.5" @click="() => emit('clear')" v-if="mapEditor.currentMap.value">Clear</button>
<button class="btn-cyan px-3.5" @click="() => mapEditor.toggleActive()">Exit</button>
<button class="btn-cyan px-3.5" @click="() => emit('close-editor')">Exit</button>
</div>
</div>
<Modal :isModalOpen="isMapEditorSettingsModalOpen" @modal:close="() => (isMapEditorSettingsModalOpen = false)" :modal-width="300" :modal-height="350" :is-resizable="false" bg-style="none">
<template #modalHeader>
<h3 class="m-0 font-medium shrink-0 text-white">Map editor settings</h3>
</template>
<template #modalBody>
<div class="m-4 flex items-center space-x-2">
<input id="continuous-drawing" @change="toggleContinuousDrawing" v-model="isContinuousDrawingEnabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<label for="continuous-drawing" class="text-sm font-medium text-gray-200 cursor-pointer"> Continuous Drawing </label>
</div>
<div class="m-4 flex items-center space-x-2">
<input id="show-placed-map-object-preview" @change="mapEditor.togglePlacedMapObjectPreview()" v-model="isShowPlacedMapObjectPreviewEnabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<label for="show-placed-map-object-preview" class="text-sm font-medium text-gray-200 cursor-pointer"> Show placed map object preview </label>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import Modal from '@/components/utilities/Modal.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { onClickOutside } from '@vueuse/core'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const mapEditor = useMapEditorComposable()
const emit = defineEmits(['save', 'clear', 'open-maps', 'open-settings', 'open-teleport'])
const emit = defineEmits(['save', 'clear', 'open-maps', 'open-settings', 'close-editor', 'open-tile-list', 'open-map-object-list', 'close-lists'])
// States
// track when clicked outside of toolbar items
const toolbar = ref(null)
const isMapEditorSettingsModalOpen = ref(false)
const selectPencilOpen = ref(false)
const selectEraserOpen = ref(false)
const isContinuousDrawingEnabled = ref<Boolean>(false)
const isShowPlacedMapObjectPreviewEnabled = ref<Boolean>(mapEditor.isPlacedMapObjectPreviewEnabled.value)
const listOpen = computed(() => (mapEditor.tool.value === 'pencil' && (mapEditor.drawMode.value === 'tile' || mapEditor.drawMode.value === 'map_object')) || mapEditor.tool.value === 'paint')
// track select state
let selectPencilOpen = ref(false)
let selectEraserOpen = ref(false)
let tileListShown = ref(false)
let mapObjectListShown = ref(false)
defineExpose({ tileListShown, mapObjectListShown })
// drawMode
function setDrawMode(value: string) {
if (mapEditor.tool.value === 'paint' || mapEditor.tool.value === 'pencil' || mapEditor.tool.value === 'eraser') {
emit('close-lists')
if (value === 'tile') emit('open-tile-list')
if (value === 'map_object') emit('open-map-object-list')
}
mapEditor.setDrawMode(value)
selectPencilOpen.value = false
selectEraserOpen.value = false
@ -137,21 +131,24 @@ function setEraserMode() {
selectEraserOpen.value = false
}
function toggleContinuousDrawing() {
mapEditor.setInputMode(isContinuousDrawingEnabled.value ? 'hold' : 'tap')
}
function handleModeClick(mode: string, type: 'pencil' | 'eraser') {
setDrawMode(mode)
type === 'pencil' ? setPencilMode() : setEraserMode()
}
function handleClick(tool: string) {
if (tool === 'settings') {
emit('open-settings')
emit('close-lists')
} else if (tool === 'move') {
emit('close-lists')
mapEditor.setTool(tool)
if (tool === 'paint') mapEditor.setDrawMode('tile')
} else {
mapEditor.setTool(tool)
}
selectPencilOpen.value = tool === 'pencil' ? !selectPencilOpen.value : false
selectEraserOpen.value = tool === 'eraser' ? !selectEraserOpen.value : false
if (mapEditor.drawMode.value === 'teleport') emit('open-teleport')
}
function cycleToolMode(tool: 'pencil' | 'eraser') {
@ -167,18 +164,15 @@ function initKeyShortcuts(event: KeyboardEvent) {
// Check if map is set
if (!mapEditor.currentMap.value) return
// prevent if focused on inputs
// prevent if focused on composables
if (document.activeElement?.tagName === 'INPUT') return
if (event.ctrlKey) return
const keyActions: { [key: string]: string } = {
m: 'move',
p: 'pencil',
e: 'eraser',
b: 'paint',
z: 'settings',
s: 'mapEditorSettings'
z: 'settings'
}
if (keyActions.hasOwnProperty(event.key)) {

View File

@ -26,8 +26,7 @@
</template>
<script setup lang="ts">
import { socketManager } from '@/managers/SocketManager'
import { login } from '@/services/authenticationService'
import { login } from '@/services/authentication'
import { useGameStore } from '@/stores/gameStore'
import { useCookies } from '@vueuse/integrations/useCookies'
import { onMounted, ref } from 'vue'
@ -40,6 +39,15 @@ const password = ref('')
const formError = ref('')
const showPassword = ref(false)
// automatic login because of development
onMounted(async () => {
const token = useCookies().get('token')
if (token) {
gameStore.setToken(token)
gameStore.initConnection()
}
})
async function submit() {
// check if username and password are valid
if (username.value === '' || password.value === '') {
@ -54,7 +62,7 @@ async function submit() {
formError.value = response.error
return
}
socketManager.setToken(response.token)
gameStore.setToken(response.token)
gameStore.initConnection()
return true // Indicate success
}

View File

@ -22,7 +22,7 @@
</template>
<script setup lang="ts">
import { newPassword } from '@/services/authenticationService'
import { newPassword } from '@/services/authentication'
import { useGameStore } from '@/stores/gameStore'
import { useCookies } from '@vueuse/integrations/useCookies'
import { onMounted, ref } from 'vue'
@ -34,6 +34,15 @@ const password = ref('')
const newPasswordError = ref('')
const showPassword = ref(false)
// automatic login because of development
onMounted(async () => {
const token = useCookies().get('token')
if (token) {
gameStore.setToken(token)
gameStore.initConnection()
}
})
async function newPasswordFunc() {
// check if username and password are valid
if (password.value === '') {

View File

@ -26,8 +26,7 @@
</template>
<script setup lang="ts">
import { socketManager } from '@/managers/SocketManager'
import { login, register } from '@/services/authenticationService'
import { login, register } from '@/services/authentication'
import { useGameStore } from '@/stores/gameStore'
import { useCookies } from '@vueuse/integrations/useCookies'
import { onMounted, ref } from 'vue'
@ -41,6 +40,15 @@ const email = ref('')
const formError = ref('')
const showPassword = ref(false)
// automatic login because of development
onMounted(async () => {
const token = useCookies().get('token')
if (token) {
gameStore.setToken(token)
gameStore.initConnection()
}
})
async function submit() {
// check if username and password are valid
if (username.value === '' || email.value === '' || password.value === '') {
@ -68,7 +76,7 @@ async function submit() {
return
}
socketManager.setToken(loginResponse.token)
gameStore.setToken(loginResponse.token)
gameStore.initConnection()
}
</script>

View File

@ -30,7 +30,7 @@
<script setup lang="ts">
import Modal from '@/components/utilities/Modal.vue'
import { resetPassword } from '@/services/authenticationService'
import { resetPassword } from '@/services/authentication'
import { useGameStore } from '@/stores/gameStore'
import { ref } from 'vue'

View File

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

View File

@ -1,7 +1,7 @@
<template>
<div class="flex justify-center items-center h-dvh relative">
<Game :config="gameConfig" @create="createGame">
<Scene name="main" @preload="preloadScene">
<Scene name="main" @preload="preloadScene" @create="createScene">
<Menu />
<Hud />
<Hotkeys />
@ -27,23 +27,18 @@ import Hotkeys from '@/components/game/gui/Hotkeys.vue'
import Hud from '@/components/game/gui/Hud.vue'
import Menu from '@/components/game/gui/Menu.vue'
import Map from '@/components/game/map/Map.vue'
import { useSoundComposable } from '@/composables/useSoundComposable'
import { useGameStore } from '@/stores/gameStore'
import { Game, Scene } from 'phavuer'
import { onMounted } from 'vue'
import { onBeforeUnmount } from 'vue'
const gameStore = useGameStore()
const { playSound, stopSound } = useSoundComposable()
const gameConfig = {
name: config.name,
width: window.innerWidth,
height: window.innerHeight,
type: Phaser.AUTO, // AUTO, CANVAS, WEBGL, HEADLESS
resolution: 5,
input: {
windowEvents: false
}
resolution: 5
}
const createGame = (game: Phaser.Game) => {
@ -60,8 +55,6 @@ const createGame = (game: Phaser.Game) => {
})
gameStore.disconnectSocket()
}
playSound('/assets/sounds/connect.wav')
}
function preloadScene(scene: Phaser.Scene) {
@ -70,7 +63,7 @@ function preloadScene(scene: Phaser.Scene) {
scene.load.image('waypoint', '/assets/waypoint.png')
}
onMounted(() => {
stopSound('/assets/music/intro.mp3')
})
function createScene(scene: Phaser.Scene) {}
onBeforeUnmount(() => {})
</script>

View File

@ -44,4 +44,13 @@ function switchToLogin() {
currentForm.value = 'login'
doesUrlHaveToken.value = false
}
// automatic login because of development
onMounted(async () => {
const token = useCookies().get('token')
if (token) {
gameStore.setToken(token)
gameStore.initConnection()
}
})
</script>

View File

@ -4,11 +4,22 @@
<Scene name="main" @preload="preloadScene">
<div v-if="!isLoaded" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-3xl font-ui">Loading...</div>
<div v-else>
<Map v-if="mapEditor.currentMap.value" :key="mapEditor.currentMap.value?.id" />
<Toolbar ref="toolbar" @save="save" @clear="clear" @open-maps="mapModal?.open" @open-settings="mapSettingsModal?.open" @open-teleport="teleportModal?.open" />
<Map :key="mapEditor.currentMap.value?.id" />
<Toolbar
ref="toolbar"
@save="save"
@clear="clear"
@open-maps="mapModal?.open"
@open-settings="mapSettingsModal?.open"
@close-editor="mapEditor.toggleActive"
@close-lists="tileList?.close"
@closeLists="objectList?.close"
@open-tile-list="tileList?.open"
@open-map-object-list="objectList?.open"
/>
<MapList ref="mapModal" @open-create-map="mapSettingsModal?.open" />
<TileList />
<MapObjectList />
<TileList ref="tileList" />
<ObjectList ref="objectList" />
<MapSettings ref="mapSettingsModal" />
<TeleportModal ref="teleportModal" />
</div>
@ -19,27 +30,30 @@
<script setup lang="ts">
import config from '@/application/config'
import { SocketEvent } from '@/application/enums'
import { socketManager } from '@/managers/SocketManager'
import 'phaser'
import type { Map as MapT } from '@/application/types'
import { downloadCache } from '@/application/utilities'
import Map from '@/components/gameMaster/mapEditor/Map.vue'
import MapList from '@/components/gameMaster/mapEditor/partials/MapList.vue'
import MapObjectList from '@/components/gameMaster/mapEditor/partials/MapObjectList.vue'
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 { loadAllTilesIntoScene } from '@/composables/mapComposable'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { loadAllTileTextures } from '@/services/mapService'
import { MapStorage } from '@/storage/storages'
import { useGameStore } from '@/stores/gameStore'
import { Game, Scene } from 'phavuer'
import { ref, toRaw, useTemplateRef } from 'vue'
import { ref, useTemplateRef, watch } from 'vue'
const mapStorage = new MapStorage()
const mapEditor = useMapEditorComposable()
const gameStore = useGameStore()
const toolbar = useTemplateRef('toolbar')
const mapModal = useTemplateRef('mapModal')
const tileList = useTemplateRef('tileList')
const objectList = useTemplateRef('objectList')
const mapSettingsModal = useTemplateRef('mapSettingsModal')
const teleportModal = useTemplateRef('teleportModal')
@ -50,10 +64,7 @@ const gameConfig = {
width: window.innerWidth,
height: window.innerHeight,
type: Phaser.AUTO, // AUTO, CANVAS, WEBGL, HEADLESS
resolution: 5,
input: {
windowEvents: false
}
resolution: 5
}
const createGame = (game: Phaser.Game) => {
@ -71,7 +82,7 @@ const preloadScene = async (scene: Phaser.Scene) => {
scene.load.image('waypoint', '/assets/waypoint.png')
// Get all tiles from IndexedDB and load them into the scene
await loadAllTileTextures(scene)
await loadAllTilesIntoScene(scene)
// Wait for all assets to be loaded before continuing
await new Promise<void>((resolve) => {
@ -82,18 +93,24 @@ const preloadScene = async (scene: Phaser.Scene) => {
})
}
async function save() {
const currentMap = toRaw(mapEditor.currentMap.value)
function save() {
const currentMap = mapEditor.currentMap.value
if (!currentMap) return
const data = {
...currentMap,
mapId: currentMap.id
mapId: currentMap.id,
name: currentMap.name,
width: currentMap.width,
height: currentMap.height,
tiles: currentMap.tiles,
pvp: currentMap.pvp,
mapEffects: currentMap.mapEffects?.map(({ id, effect, strength }) => ({ id, effect, strength })) ?? [],
mapEventTiles: currentMap.mapEventTiles?.map(({ id, type, positionX, positionY, teleport }) => ({ id, type, positionX, positionY, teleport })) ?? [],
placedMapObjects: currentMap.placedMapObjects?.map(({ id, mapObject, depth, isRotated, positionX, positionY }) => ({ id, mapObject, depth, isRotated, positionX, positionY })) ?? []
}
socketManager.emit(SocketEvent.GM_MAP_UPDATE, data, async (response: MapT) => {
if (!response.id) return
await downloadCache('maps', new MapStorage())
gameStore.connection?.emit('gm:map:update', data, (response: MapT) => {
mapStorage.update(response.id, response)
})
}
@ -101,6 +118,6 @@ function clear() {
if (!mapEditor.currentMap.value) return
// Clear placed objects, event tiles and tiles
mapEditor.triggerClearTiles()
mapEditor.clearMap()
}
</script>

View File

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

View File

@ -0,0 +1,23 @@
<template>
<div style="display: none">
<img v-for="(url, index) in imageUrls" :key="index" :src="url" alt="" @load="handleImageLoad(index)" @error="handleImageError(index)" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// Internal array of images to preload
const imageUrls = ref<string[]>(['/assets/ui-elements/button-ui-box-textured.svg', '/assets/ui-elements/button-ui-frame-empty.svg', '/assets/ui-elements/button-ui-box-textured-small.svg'])
const loadedImages = ref<Set<number>>(new Set())
const handleImageLoad = (index: number) => {
loadedImages.value.add(index)
console.log(`Image ${index} loaded:`, imageUrls.value[index])
}
const handleImageError = (index: number) => {
console.log(`Image ${index} failed to load:`, imageUrls.value[index])
}
</script>

View File

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

View File

@ -14,11 +14,11 @@
<slot name="modalHeader" />
</div>
<div class="flex gap-2.5">
<button v-if="canFullScreen" @click="toggleFullScreen" class="w-5 h-5 m-0 p-0 relative hover:scale-110 transition-transform duration-300 ease-in-out btn-sound">
<button v-if="canFullScreen" @click="toggleFullScreen" class="w-5 h-5 m-0 p-0 relative hover:scale-110 transition-transform duration-300 ease-in-out">
<img :alt="isFullScreen ? 'exit full-screen' : 'full-screen'" :src="isFullScreen ? '/assets/icons/modal/minimize.svg' : '/assets/icons/modal/increase-size-option.svg'" class="w-3.5 h-3.5 invert" draggable="false" />
</button>
<button v-if="closable" @click="closeModal" class="w-3.5 h-3.5 m-0 p-0 relative hover:rotate-180 transition-transform duration-300 ease-in-out btn-sound">
<img alt="close" src="/assets/icons/modal/close-button-white.svg" class="w-full h-full btn-sound" draggable="false" />
<button v-if="closable" @click="closeModal" 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" src="/assets/icons/modal/close-button-white.svg" class="w-full h-full" draggable="false" />
</button>
</div>
</div>
@ -86,11 +86,7 @@ const emit = defineEmits<{
defineExpose({
open: () => (isModalOpenRef.value = true),
close: () => (isModalOpenRef.value = false),
toggle: () => (isModalOpenRef.value = !isModalOpenRef.value),
setPosition: (a: number, b: number) => {
x.value = a
y.value = b
}
toggle: () => (isModalOpenRef.value = !isModalOpenRef.value)
})
const isModalOpenRef = ref(props.isModalOpen)

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