1
0
forked from noxious/server

Renamed folder

This commit is contained in:
2024-12-28 22:12:59 +01:00
parent 0bc81ba4cc
commit e1ff2fefe1
49 changed files with 2 additions and 2 deletions

View File

@ -0,0 +1,28 @@
import { BaseEvent } from '#application/base/baseEvent'
import { CharacterHair } from '#entities/characterHair'
import characterRepository from '#repositories/characterRepository'
export default class CharacterHairCreateEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:characterHair:create', this.handleEvent.bind(this))
}
private async handleEvent(data: undefined, callback: (response: boolean, characterType?: any) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
const newCharacterHair = new CharacterHair()
await newCharacterHair.setName('New hair').save()
callback(true, newCharacterHair)
} catch (error) {
console.error('Error creating character hair:', error)
callback(false)
}
}
}

View File

@ -0,0 +1,34 @@
import { BaseEvent } from '#application/base/baseEvent'
import CharacterHairRepository from '#repositories/characterHairRepository'
import characterRepository from '#repositories/characterRepository'
interface IPayload {
id: number
}
export default class characterHairDeleteEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:characterHair:remove', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
const characterHair = await CharacterHairRepository.getById(data.id)
if (characterHair) {
await characterHair.delete()
}
callback(true)
} catch (error) {
this.logger.error(`Error deleting character type ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
}
}
}

View File

@ -0,0 +1,29 @@
import { BaseEvent } from '#application/base/baseEvent'
import { CharacterHair } from '#entities/characterHair'
import characterHairRepository from '#repositories/characterHairRepository'
import characterRepository from '#repositories/characterRepository'
interface IPayload {}
export default class characterHairListEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:characterHair:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: CharacterHair[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) {
this.logger.error('gm:characterHair:list error', 'Character not found')
return callback([])
}
if (character.role !== 'gm') {
this.logger.info(`User ${character.id} tried to list character hair but is not a game master.`)
return callback([])
}
// get all objects
const items = await characterHairRepository.getAll()
callback(items)
}
}

View File

@ -0,0 +1,43 @@
import { BaseEvent } from '#application/base/baseEvent'
import { CharacterGender } from '#application/enums'
import { UUID } from '#application/types'
import CharacterHairRepository from '#repositories/characterHairRepository'
import characterRepository from '#repositories/characterRepository'
import SpriteRepository from '#repositories/spriteRepository'
type Payload = {
id: number
name: string
gender: CharacterGender
isSelectable: boolean
spriteId: UUID
}
export default class CharacterHairUpdateEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:characterHair:update', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
const sprite = await SpriteRepository.getById(data.spriteId)
const characterHair = await CharacterHairRepository.getById(data.id)
if (characterHair) {
await characterHair.setName(data.name).setGender(data.gender).setIsSelectable(data.isSelectable).setSprite(sprite!).update()
}
return callback(true)
} catch (error) {
this.logger.error(`Error updating character hair: ${error instanceof Error ? error.message : String(error)}`)
return callback(false)
}
}
}

View File

@ -0,0 +1,41 @@
import { CharacterGender, CharacterRace } from '@prisma/client'
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
export default class CharacterTypeCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterType:create', this.handleEvent.bind(this))
}
private async handleEvent(data: undefined, callback: (response: boolean, characterType?: any) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
const newCharacterType = await prisma.characterType.create({
data: {
name: 'New character type',
gender: CharacterGender.MALE,
race: CharacterRace.HUMAN
}
})
callback(true, newCharacterType)
} catch (error) {
console.error('Error creating character type:', error)
callback(false)
}
}
}

View File

@ -0,0 +1,41 @@
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import CharacterTypeRepository from '#repositories/characterTypeRepository'
interface IPayload {
id: number
}
export default class CharacterTypeDeleteEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterType:remove', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
const characterType = await CharacterTypeRepository.getById(data.id)
if (!characterType) return callback(false)
await characterType.delete()
callback(true)
} catch (error) {
gameMasterLogger.error(`Error deleting character type ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
}
}
}

View File

@ -0,0 +1,37 @@
import { CharacterType } from '@prisma/client'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import CharacterTypeRepository from '#repositories/characterTypeRepository'
interface IPayload {}
export default class CharacterTypeListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterType:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: CharacterType[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:characterType:list error', 'Character not found')
return callback([])
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to list character types but is not a game master.`)
return callback([])
}
// get all objects
const items = await CharacterTypeRepository.getAll()
callback(items)
}
}

View File

@ -0,0 +1,53 @@
import { CharacterGender, CharacterRace } from '@prisma/client'
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
type Payload = {
id: number
name: string
gender: CharacterGender
race: CharacterRace
isSelectable: boolean
spriteId: string
}
export default class CharacterTypeUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterType:update', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
await prisma.characterType.update({
where: { id: data.id },
data: {
name: data.name,
gender: data.gender,
race: data.race,
isSelectable: data.isSelectable,
spriteId: data.spriteId
}
})
callback(true)
} catch (error) {
console.error(error)
callback(false)
}
}
}

View File

@ -0,0 +1,42 @@
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
export default class ItemCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:item:create', this.handleEvent.bind(this))
}
private async handleEvent(data: undefined, callback: (response: boolean, item?: any) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
const newItem = await prisma.item.create({
data: {
name: 'New Item',
itemType: 'WEAPON',
stackable: false,
rarity: 'COMMON',
spriteId: null
}
})
callback(true, newItem)
} catch (error) {
console.error('Error creating item:', error)
callback(false)
}
}
}

View File

@ -0,0 +1,41 @@
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
interface IPayload {
id: string
}
export default class ItemDeleteEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:item:remove', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
await prisma.item.delete({
where: { id: data.id }
})
callback(true)
} catch (error) {
gameMasterLogger.error(`Error deleting item ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
}
}
}

View File

@ -0,0 +1,37 @@
import { Item } from '@prisma/client'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import itemRepository from '#repositories/itemRepository'
interface IPayload {}
export default class ItemListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:item:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Item[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:item:list error', 'Character not found')
return callback([])
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to list items but is not a game master.`)
return callback([])
}
// get all items
const items = await itemRepository.getAll()
callback(items)
}
}

View File

@ -0,0 +1,56 @@
import { ItemType, ItemRarity } from '@prisma/client'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
type Payload = {
id: string
name: string
description: string | null
itemType: ItemType
stackable: boolean
rarity: ItemRarity
spriteId: string | null
}
export default class ItemUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:item:update', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
await prisma.item.update({
where: { id: data.id },
data: {
name: data.name,
description: data.description,
itemType: data.itemType,
stackable: data.stackable,
rarity: data.rarity,
spriteId: data.spriteId
}
})
return callback(true)
} catch (error) {
gameMasterLogger.error(`Error updating item: ${error instanceof Error ? error.message : String(error)}`)
return callback(false)
}
}
}

View File

@ -0,0 +1,32 @@
import { Object } from '@prisma/client'
import { Server } from 'socket.io'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import ObjectRepository from '#repositories/objectRepository'
interface IPayload {}
export default class ObjectListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Object[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback([])
if (character.role !== 'gm') {
return callback([])
}
// get all objects
const objects = await ObjectRepository.getAll()
callback(objects)
}
}

View File

@ -0,0 +1,59 @@
import fs from 'fs'
import { Server } from 'socket.io'
import { gameLogger, gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
interface IPayload {
object: string
}
export default class ObjectRemoveEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:remove', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
await prisma.object.delete({
where: {
id: data.object
}
})
// get root path
const public_folder = getPublicPath('objects')
// remove the tile from the disk
const finalFilePath = getPublicPath('objects', data.object + '.png')
fs.unlink(finalFilePath, (err) => {
if (err) {
gameMasterLogger.error(`Error deleting object ${data.object}: ${err.message}`)
callback(false)
return
}
callback(true)
})
} catch (error) {
gameLogger.error(`Error deleting object ${data.object}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
}
}
}

View File

@ -0,0 +1,59 @@
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
type Payload = {
id: string
name: string
tags: string[]
originX: number
originY: number
isAnimated: boolean
frameRate: number
frameWidth: number
frameHeight: number
}
export default class ObjectUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:update', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
const object = await prisma.object.update({
where: {
id: data.id
},
data: {
name: data.name,
tags: data.tags,
originX: data.originX,
originY: data.originY,
isAnimated: data.isAnimated,
frameRate: data.frameRate,
frameWidth: data.frameWidth,
frameHeight: data.frameHeight
}
})
callback(true)
} catch (error) {
console.error(error)
callback(false)
}
}
}

View File

@ -0,0 +1,73 @@
import fs from 'fs/promises'
import { writeFile } from 'node:fs/promises'
import sharp from 'sharp'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
interface IObjectData {
[key: string]: Buffer
}
export default class ObjectUploadEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:upload', this.handleEvent.bind(this))
}
private async handleEvent(data: IObjectData, callback: (response: boolean) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
const public_folder = getPublicPath('objects')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
const uploadPromises = Object.entries(data).map(async ([key, objectData]) => {
// Get image dimensions
const metadata = await sharp(objectData).metadata()
const width = metadata.width || 0
const height = metadata.height || 0
const object = await prisma.object.create({
data: {
name: key,
tags: [],
originX: 0,
originY: 0,
frameWidth: width,
frameHeight: height
}
})
const uuid = object.id
const filename = `${uuid}.png`
const finalFilePath = getPublicPath('objects', filename)
await writeFile(finalFilePath, objectData)
gameMasterLogger.info('gm:object:upload', `Object ${key} uploaded with id ${uuid}`)
})
await Promise.all(uploadPromises)
callback(true)
} catch (error: any) {
gameMasterLogger.error('gm:object:upload error', error.message)
callback(false)
}
}
}

View File

@ -0,0 +1,79 @@
import { Server } from 'socket.io'
import type { Prisma } from '@prisma/client'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
interface CopyPayload {
id: string
}
export default class SpriteCopyEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:sprite:copy', this.handleEvent.bind(this))
}
private async handleEvent(payload: CopyPayload, callback: (success: boolean) => void): Promise<void> {
try {
if (!(await this.validateGameMasterAccess())) {
return callback(false)
}
const sourceSprite = await prisma.sprite.findUnique({
where: { id: payload.id },
include: {
spriteActions: true
}
})
if (!sourceSprite) {
throw new Error('Source sprite not found')
}
const newSprite = await prisma.sprite.create({
data: {
name: `${sourceSprite.name} (Copy)`,
spriteActions: {
create: sourceSprite.spriteActions.map((action) => ({
action: action.action,
sprites: action.sprites as Prisma.InputJsonValue,
originX: action.originX,
originY: action.originY,
isAnimated: action.isAnimated,
isLooping: action.isLooping,
frameWidth: action.frameWidth,
frameHeight: action.frameHeight,
frameRate: action.frameRate
}))
}
}
})
callback(true)
} catch (error) {
this.handleError(error, payload.id, callback)
}
}
private async validateGameMasterAccess(): Promise<boolean> {
const character = await CharacterRepository.getById(this.socket.characterId!)
return character?.role === 'gm'
}
private handleError(error: unknown, spriteId: string, callback: (success: boolean) => void): void {
gameMasterLogger.error(`Error copying sprite ${spriteId}: ${this.getErrorMessage(error)}`)
callback(false)
}
private getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
}

View File

@ -0,0 +1,51 @@
import fs from 'fs/promises'
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
export default class SpriteCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:sprite:create', this.handleEvent.bind(this))
}
private async handleEvent(data: undefined, callback: (response: boolean) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId!)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
const public_folder = getPublicPath('sprites')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
const sprite = await prisma.sprite.create({
data: {
name: 'New sprite'
}
})
const uuid = sprite.id
// Create folder with uuid
const sprite_folder = getPublicPath('sprites', uuid)
await fs.mkdir(sprite_folder, { recursive: true })
callback(true)
} catch (error) {
console.error('Error creating sprite:', error)
callback(false)
}
}
}

View File

@ -0,0 +1,62 @@
import fs from 'fs'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
type Payload = {
id: string
}
export default class GMSpriteDeleteEvent {
private readonly public_folder: string
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {
this.public_folder = getPublicPath('sprites')
}
public listen(): void {
this.socket.on('gm:sprite:delete', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (response: boolean) => void): Promise<void> {
const character = await CharacterRepository.getById(this.socket.characterId!)
if (character?.role !== 'gm') {
return callback(false)
}
try {
await this.deleteSpriteFolder(data.id)
await this.deleteSpriteFromDatabase(data.id)
gameMasterLogger.info(`Sprite ${data.id} deleted.`)
callback(true)
} catch (error: any) {
gameMasterLogger.error('gm:sprite:delete error', error.message)
callback(false)
}
}
private async deleteSpriteFolder(spriteId: string): Promise<void> {
const finalFilePath = getPublicPath('sprites', spriteId)
if (fs.existsSync(finalFilePath)) {
await fs.promises.rmdir(finalFilePath, { recursive: true })
}
}
private async deleteSpriteFromDatabase(spriteId: string): Promise<void> {
await prisma.sprite.delete({
where: {
id: spriteId
}
})
}
}

View File

@ -0,0 +1,32 @@
import { Sprite } from '@prisma/client'
import { Server } from 'socket.io'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import SpriteRepository from '#repositories/spriteRepository'
interface IPayload {}
export default class SpriteListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:sprite:list', this.handleEvent.bind(this))
}
private async handleEvent(data: any, callback: (response: Sprite[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId!)
if (!character) return callback([])
if (character.role !== 'gm') {
return callback([])
}
// get all sprites
const sprites = await SpriteRepository.getAll()
callback(sprites)
}
}

View File

@ -0,0 +1,402 @@
import { writeFile, mkdir } from 'node:fs/promises'
import sharp from 'sharp'
import { Server } from 'socket.io'
import type { Prisma, SpriteAction } from '@prisma/client'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
// Constants
const ISOMETRIC_CONFIG = {
tileWidth: 64,
tileHeight: 32,
centerOffset: 32,
bodyRatios: {
topStart: 0.15,
topEnd: 0.45,
weightUpper: 0.7,
weightLower: 0.3
}
} as const
// Types
interface ContentBounds {
left: number
right: number
top: number
bottom: number
width: number
height: number
}
interface SpriteActionInput extends Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> {
sprites: string[]
}
interface UpdatePayload {
id: string
name: string
spriteActions: Prisma.JsonValue
}
interface ProcessedSpriteAction extends SpriteActionInput {
frameWidth: number
frameHeight: number
buffersWithDimensions: ProcessedFrame[]
}
interface ProcessedFrame {
buffer: Buffer
width: number
height: number
}
interface SpriteAnalysis {
massCenter: number
spinePosition: number
contentBounds: ContentBounds
}
export default class SpriteUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:sprite:update', this.handleEvent.bind(this))
}
private async handleEvent(payload: UpdatePayload, callback: (success: boolean) => void): Promise<void> {
try {
if (!(await this.validateGameMasterAccess())) {
return callback(false)
}
const parsedActions = this.validateSpriteActions(payload.spriteActions)
// Process sprites
const processedActions = await Promise.all(
parsedActions.map(async (action) => {
const spriteBuffers = await this.convertBase64ToBuffers(action.sprites)
const frameWidth = ISOMETRIC_CONFIG.tileWidth
const frameHeight = await this.calculateOptimalHeight(spriteBuffers)
const processedFrames = await this.normalizeFrames(spriteBuffers, frameWidth, frameHeight)
return {
...action,
frameWidth,
frameHeight,
buffersWithDimensions: processedFrames
}
})
)
await Promise.all([
this.updateDatabase(payload.id, payload.name, processedActions),
this.saveSpritesToDisk(
payload.id,
processedActions.filter((a) => a.buffersWithDimensions.length > 0)
)
])
callback(true)
} catch (error) {
this.handleError(error, payload.id, callback)
}
}
private async validateGameMasterAccess(): Promise<boolean> {
const character = await CharacterRepository.getById(this.socket.characterId!)
return character?.role === 'gm'
}
private validateSpriteActions(actions: Prisma.JsonValue): SpriteActionInput[] {
try {
const parsed = JSON.parse(JSON.stringify(actions)) as SpriteActionInput[]
if (!Array.isArray(parsed)) {
throw new Error('Sprite actions must be an array')
}
return parsed
} catch (error) {
throw new Error(`Invalid sprite actions format: ${this.getErrorMessage(error)}`)
}
}
private async convertBase64ToBuffers(sprites: string[]): Promise<Buffer[]> {
return sprites.map((sprite) => Buffer.from(sprite.split(',')[1], 'base64'))
}
private async normalizeFrames(buffers: Buffer[], frameWidth: number, frameHeight: number): Promise<ProcessedFrame[]> {
return Promise.all(
buffers.map(async (buffer) => {
const normalizedBuffer = await this.normalizeIsometricSprite(buffer, frameWidth, frameHeight)
return {
buffer: normalizedBuffer,
width: frameWidth,
height: frameHeight
}
})
)
}
private async calculateOptimalHeight(buffers: Buffer[]): Promise<number> {
if (!buffers.length) return ISOMETRIC_CONFIG.tileHeight // Return default height if no buffers
const heights = await Promise.all(
buffers.map(async (buffer) => {
const bounds = await this.findContentBounds(buffer)
return bounds.height
})
)
return Math.ceil(Math.max(...heights) / 2) * 2
}
private async normalizeIsometricSprite(buffer: Buffer, frameWidth: number, frameHeight: number): Promise<Buffer> {
const analysis = await this.analyzeIsometricSprite(buffer)
const idealCenter = Math.floor(frameWidth / 2)
const offset = Math.round(idealCenter - analysis.massCenter)
// Process the input sprite
const processedInput = await sharp(buffer)
.ensureAlpha()
.resize({
width: frameWidth, // Set maximum width
height: frameHeight, // Set maximum height
fit: 'inside', // Ensure image fits within dimensions
kernel: sharp.kernel.nearest,
position: 'center',
withoutEnlargement: true // Don't enlarge smaller images
})
.png({
compressionLevel: 9,
adaptiveFiltering: false,
palette: true,
quality: 100,
colors: 256
})
.toBuffer()
// Create the final composition
return sharp({
create: {
width: frameWidth,
height: frameHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.composite([
{
input: processedInput,
left: offset,
top: 0,
blend: 'over'
}
])
.png({
compressionLevel: 9,
adaptiveFiltering: false,
palette: true,
quality: 100,
colors: 256
})
.toBuffer()
}
private async analyzeIsometricSprite(buffer: Buffer): Promise<SpriteAnalysis> {
const { data, info } = await sharp(buffer).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
const { width, height } = info
const upperStart = Math.floor(height * ISOMETRIC_CONFIG.bodyRatios.topStart)
const upperEnd = Math.floor(height * ISOMETRIC_CONFIG.bodyRatios.topEnd)
const { columnDensity, upperBodyDensity, bounds } = this.calculatePixelDistribution(data, width, height, upperStart, upperEnd)
const spinePosition = this.findSpinePosition(upperBodyDensity)
const massCenter = this.calculateWeightedMassCenter(columnDensity, upperBodyDensity)
return {
massCenter,
spinePosition,
contentBounds: bounds
}
}
private calculatePixelDistribution(data: Buffer, width: number, height: number, upperStart: number, upperEnd: number) {
const columnDensity = new Array(width).fill(0)
const upperBodyDensity = new Array(width).fill(0)
const bounds = { left: width, right: 0, top: height, bottom: 0 }
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3] > 0) {
columnDensity[x]++
if (y >= upperStart && y <= upperEnd) {
upperBodyDensity[x]++
}
this.updateBounds(bounds, x, y)
}
}
}
return {
columnDensity,
upperBodyDensity,
bounds: {
...bounds,
width: bounds.right - bounds.left + 1,
height: bounds.bottom - bounds.top + 1
}
}
}
private updateBounds(bounds: { left: number; right: number; top: number; bottom: number }, x: number, y: number): void {
bounds.left = Math.min(bounds.left, x)
bounds.right = Math.max(bounds.right, x)
bounds.top = Math.min(bounds.top, y)
bounds.bottom = Math.max(bounds.bottom, y)
}
private findSpinePosition(density: number[]): number {
return density.reduce((maxIdx, curr, idx, arr) => (curr > arr[maxIdx] ? idx : maxIdx), 0)
}
private calculateWeightedMassCenter(columnDensity: number[], upperBodyDensity: number[]): number {
const upperMassCenter = this.calculateMassCenter(upperBodyDensity)
const lowerMassCenter = this.calculateMassCenter(columnDensity)
return Math.round(upperMassCenter * ISOMETRIC_CONFIG.bodyRatios.weightUpper + lowerMassCenter * ISOMETRIC_CONFIG.bodyRatios.weightLower)
}
private calculateMassCenter(density: number[]): number {
const totalMass = density.reduce((sum, mass) => sum + mass, 0)
if (!totalMass) return 0
const weightedSum = density.reduce((sum, mass, position) => sum + position * mass, 0)
return Math.round(weightedSum / totalMass)
}
private async findContentBounds(buffer: Buffer) {
const { data, info } = await sharp(buffer).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
const width = info.width
const height = info.height
let left = width
let right = 0
let top = height
let bottom = 0
// Find actual content boundaries by checking alpha channel
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4
if (data[idx + 3] > 0) {
// If pixel is not transparent
left = Math.min(left, x)
right = Math.max(right, x)
top = Math.min(top, y)
bottom = Math.max(bottom, y)
}
}
}
return {
width: right - left + 1,
height: bottom - top + 1,
leftOffset: left,
topOffset: top
}
}
private async saveSpritesToDisk(id: string, actions: ProcessedSpriteAction[]): Promise<void> {
const publicFolder = getPublicPath('sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all(
actions.map(async (action) => {
const spritesheet = await this.createSpritesheet(action.buffersWithDimensions)
await writeFile(getPublicPath('sprites', id, `${action.action}.png`), spritesheet)
})
)
}
private async createSpritesheet(frames: ProcessedFrame[]): Promise<Buffer> {
const background = await sharp({
create: {
width: ISOMETRIC_CONFIG.tileWidth * frames.length,
height: frames[0].height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.png({
compressionLevel: 9,
adaptiveFiltering: false,
palette: true,
quality: 100,
colors: 256,
dither: 0
})
.toBuffer()
return sharp(background)
.composite(
frames.map((frame, index) => ({
input: frame.buffer,
left: index * ISOMETRIC_CONFIG.tileWidth,
top: 0,
blend: 'over'
}))
)
.png({
compressionLevel: 9,
adaptiveFiltering: false,
palette: true,
quality: 100,
colors: 256,
dither: 0
})
.toBuffer()
}
private async updateDatabase(id: string, name: string, actions: ProcessedSpriteAction[]): Promise<void> {
await prisma.sprite.update({
where: { id },
data: {
name,
spriteActions: {
deleteMany: { spriteId: id },
create: actions.map(this.mapActionToDatabase)
}
}
})
}
private mapActionToDatabase(action: ProcessedSpriteAction) {
return {
action: action.action,
sprites: action.sprites,
originX: action.originX,
originY: action.originY,
isAnimated: action.isAnimated,
isLooping: action.isLooping,
frameWidth: action.frameWidth,
frameHeight: action.frameHeight,
frameRate: action.frameRate
}
}
private handleError(error: unknown, spriteId: string, callback: (success: boolean) => void): void {
gameMasterLogger.error(`Error updating sprite ${spriteId}: ${this.getErrorMessage(error)}`)
callback(false)
}
private getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
}

View File

@ -0,0 +1,69 @@
import fs from 'fs/promises'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
type Payload = {
id: string
}
export default class GMTileDeleteEvent {
private readonly public_folder: string
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {
this.public_folder = getPublicPath('tiles')
}
public listen(): void {
this.socket.on('gm:tile:delete', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (response: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return
}
try {
gameMasterLogger.info(`Deleting tile ${data.id}`)
await this.deleteTileFromDatabase(data.id)
await this.deleteTileFile(data.id)
gameMasterLogger.info(`Tile ${data.id} deleted successfully.`)
callback(true)
} catch (error: any) {
gameMasterLogger.error('gm:tile:delete error', error.message)
callback(false)
}
}
private async deleteTileFromDatabase(tileId: string): Promise<void> {
await prisma.tile.delete({
where: {
id: tileId
}
})
}
private async deleteTileFile(tileId: string): Promise<void> {
const finalFilePath = getPublicPath('tiles', `${tileId}.png`)
try {
await fs.unlink(finalFilePath)
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error
}
gameMasterLogger.warn(`File ${finalFilePath} does not exist.`)
}
}
}

View File

@ -0,0 +1,32 @@
import { Tile } from '@prisma/client'
import { Server } from 'socket.io'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import TileRepository from '#repositories/tileRepository'
interface IPayload {}
export default class TileListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:tile:list', this.handleEvent.bind(this))
}
private async handleEvent(data: any, callback: (response: Tile[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return
if (character.role !== 'gm') {
return
}
// get all tiles
const tiles = await TileRepository.getAll()
callback(tiles)
}
}

View File

@ -0,0 +1,48 @@
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
type Payload = {
id: string
name: string
tags: string[]
}
export default class TileUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:tile:update', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return
}
try {
const Tile = await prisma.tile.update({
where: {
id: data.id
},
data: {
name: data.name,
tags: data.tags
}
})
callback(true)
} catch (error) {
console.error(error)
callback(false)
}
}
}

View File

@ -0,0 +1,60 @@
import fs from 'fs/promises'
import { writeFile } from 'node:fs/promises'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
interface ITileData {
[key: string]: Buffer
}
export default class TileUploadEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:tile:upload', this.handleEvent.bind(this))
}
private async handleEvent(data: ITileData, callback: (response: boolean) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return
}
const public_folder = getPublicPath('tiles')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
const uploadPromises = Object.entries(data).map(async ([key, tileData]) => {
const tile = await prisma.tile.create({
data: {
name: 'New tile'
}
})
const uuid = tile.id
const filename = `${uuid}.png`
const finalFilePath = getPublicPath('tiles', filename)
await writeFile(finalFilePath, tileData)
})
await Promise.all(uploadPromises)
callback(true)
} catch (error) {
gameMasterLogger.error('Error uploading tile:', error)
callback(false)
}
}
}