Merge remote-tracking branch 'origin/main' into feature/#362-final-depth-sort-fix

# Conflicts:
#	src/entities/base/mapObject.ts
#	src/events/gameMaster/assetManager/mapObject/update.ts
This commit is contained in:
2025-03-14 00:18:14 +01:00
38 changed files with 1148 additions and 707 deletions

View File

@ -1,6 +1,7 @@
import { BaseEvent } from '@/application/base/baseEvent'
import { SocketEvent } from '@/application/enums'
import { CharacterGender, SocketEvent } from '@/application/enums'
import { CharacterHair } from '@/entities/characterHair'
import SpriteRepository from '@/repositories/spriteRepository'
export default class CharacterHairCreateEvent extends BaseEvent {
public listen(): void {
@ -11,8 +12,17 @@ export default class CharacterHairCreateEvent extends BaseEvent {
try {
if (!(await this.isCharacterGM())) return
// Get first sprite
const spriteRepository = new SpriteRepository()
const firstSprite = await spriteRepository.getFirst()
if (!firstSprite) {
this.sendNotificationAndLog('No sprites found')
return callback(false)
}
const newCharacterHair = new CharacterHair()
await newCharacterHair.setName('New hair').save()
await newCharacterHair.setName('New hair').setGender(CharacterGender.MALE).setSprite(firstSprite).save()
return callback(true)
} catch (error) {

View File

@ -8,6 +8,7 @@ type Payload = {
id: UUID
name: string
gender: CharacterGender
color: string
isSelectable: boolean
spriteId: UUID
}
@ -29,7 +30,7 @@ export default class CharacterHairUpdateEvent extends BaseEvent {
const characterHair = await characterHairRepository.getById(data.id)
if (!characterHair) return callback(false)
await characterHair.setName(data.name).setGender(data.gender).setIsSelectable(data.isSelectable).setSprite(sprite).setUpdatedAt(new Date()).save()
await characterHair.setName(data.name).setGender(data.gender).setColor(data.color).setIsSelectable(data.isSelectable).setSprite(sprite).setUpdatedAt(new Date()).save()
return callback(true)
} catch (error) {

View File

@ -14,7 +14,10 @@ export default class ItemCreateEvent extends BaseEvent {
const spriteRepository = new SpriteRepository()
const sprite = await spriteRepository.getFirst()
if (!sprite) return callback(false)
if (!sprite) {
this.sendNotificationAndLog('No sprites found')
return callback(false)
}
const newItem = new Item()
await newItem.setName('New Item').setItemType(ItemType.WEAPON).setStackable(false).setRarity(ItemRarity.COMMON).setSprite(sprite).save()

View File

@ -8,6 +8,7 @@ type Payload = {
name: string
tags: string[]
depthOffsets: number[]
pivotPoints: { x: number; y: number }[]
originX: number
originY: number
frameRate: number
@ -32,6 +33,7 @@ export default class MapObjectUpdateEvent extends BaseEvent {
if (data.name !== undefined) mapObject.name = data.name
if (data.tags !== undefined) mapObject.tags = data.tags
if (data.depthOffsets !== undefined) mapObject.depthOffsets = data.depthOffsets
if (data.pivotPoints !== undefined) mapObject.pivotPoints = data.pivotPoints
if (data.originX !== undefined) mapObject.originX = data.originX
if (data.originY !== undefined) mapObject.originY = data.originY
if (data.frameRate !== undefined) mapObject.frameRate = data.frameRate

View File

@ -2,6 +2,7 @@ import { BaseEvent } from '@/application/base/baseEvent'
import { SocketEvent } from '@/application/enums'
import type { UUID } from '@/application/types'
import { Sprite } from '@/entities/sprite'
import { SpriteAction } from '@/entities/spriteAction'
import SpriteRepository from '@/repositories/spriteRepository'
interface CopyPayload {
@ -29,7 +30,21 @@ export default class SpriteCopyEvent extends BaseEvent {
await spriteRepository.getEntityManager().populate(sourceSprite, ['spriteActions'])
const newSprite = new Sprite()
await newSprite.setName(`${sourceSprite.getName()} (Copy)`).setSpriteActions(sourceSprite.getSpriteActions()).save()
await newSprite.setName(`${sourceSprite.getName()} (Copy)`).save()
for (const spriteAction of sourceSprite.getSpriteActions()) {
const newSpriteAction = new SpriteAction()
await newSpriteAction
.setSprite(newSprite)
.setAction(spriteAction.getAction())
.setSprites(spriteAction.getSprites() ?? [])
.setOriginX(spriteAction.getOriginX())
.setOriginY(spriteAction.getOriginY())
.setFrameWidth(spriteAction.getFrameWidth())
.setFrameHeight(spriteAction.getFrameHeight())
.setFrameRate(spriteAction.getFrameRate())
.save()
}
return callback(true)
} catch (error) {

View File

@ -14,76 +14,103 @@ interface SpriteImage {
}
}
interface ImageDimensions {
width: number
height: number
offsetX: number
offsetY: number
interface SpriteActionData {
action: string
sprites: SpriteImage[]
originX: number
originY: number
frameRate: number
}
interface EffectiveDimensions {
width: number
height: number
top: number
bottom: number
}
type Payload = {
interface UpdateSpritePayload {
id: UUID
name: string
spriteActions: Array<{
action: string
sprites: SpriteImage[]
originX: number
originY: number
frameRate: number
}>
spriteActions: SpriteActionData[]
}
export default class SpriteUpdateEvent extends BaseEvent {
private readonly spriteRepository: SpriteRepository = new SpriteRepository()
public listen(): void {
this.socket.on(SocketEvent.GM_SPRITE_UPDATE, this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (success: boolean) => void): Promise<void> {
private async handleEvent(data: UpdateSpritePayload, callback: (success: boolean) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
const spriteRepository = new SpriteRepository()
const sprite = await spriteRepository.getById(data.id)
if (!sprite) return callback(false)
await spriteRepository.getEntityManager().populate(sprite, ['spriteActions'])
// Update sprite in database
await sprite.setName(data.name).save()
// First verify all sprite sheets can be generated
for (const actionData of data.spriteActions) {
if (!(await this.generateSpriteSheet(actionData.sprites, sprite.getId(), actionData.action))) {
return callback(false)
}
// Validate request and permissions
if (!(await this.isCharacterGM())) {
callback(false)
return
}
// Get and validate sprite
const sprite = await this.spriteRepository.getById(data.id)
if (!sprite) {
callback(false)
return
}
await this.spriteRepository.getEntityManager().populate(sprite, ['spriteActions'])
// Update sprite name
await sprite.setName(data.name).setUpdatedAt(new Date()).save()
// Process all sprite actions
const success = await this.processAllSpriteActions(data.spriteActions, sprite)
callback(success)
} catch (error) {
console.error(`Error updating sprite ${data.id}:`, error)
callback(false)
}
}
private async processAllSpriteActions(actionsData: SpriteActionData[], sprite: any): Promise<boolean> {
try {
// Remove existing actions
const existingActions = sprite.getSpriteActions()
// Remove existing actions only after confirming sprite sheets generated successfully
for (const existingAction of existingActions) {
await spriteRepository.getEntityManager().removeAndFlush(existingAction)
await this.spriteRepository.getEntityManager().removeAndFlush(existingAction)
}
// Create new actions
for (const actionData of data.spriteActions) {
// Process images and calculate dimensions
const imageData = await Promise.all(actionData.sprites.map((sprite) => this.processImage(sprite)))
const effectiveDimensions = imageData.map((dimensions) => this.calculateEffectiveDimensions(dimensions))
// First pass: find the global maximum dimensions across all actions
let globalMaxWidth = 0
let globalMaxHeight = 0
// Calculate total height needed for the sprite sheet
const maxHeight = Math.max(...effectiveDimensions.map((d) => d.height))
const maxTop = Math.max(...effectiveDimensions.map((d) => d.top))
const maxBottom = Math.max(...effectiveDimensions.map((d) => d.bottom))
const totalHeight = maxHeight + maxTop + maxBottom
// Extract all image metadata to find global maximums
for (const actionData of actionsData) {
if (actionData.sprites.length === 0) continue
const imagesData = await Promise.all(
actionData.sprites.map(async (sprite) => {
const base64Data = sprite.url.split(';base64,').pop()
if (!base64Data) throw new Error('Invalid base64 image')
const buffer = Buffer.from(base64Data, 'base64')
const metadata = await sharp(buffer).metadata()
return {
width: metadata.width || 0,
height: metadata.height || 0
}
})
)
// Update global maximums with this action's maximums
const actionMaxWidth = Math.max(...imagesData.map((data) => data.width), 0)
const actionMaxHeight = Math.max(...imagesData.map((data) => data.height), 0)
globalMaxWidth = Math.max(globalMaxWidth, actionMaxWidth)
globalMaxHeight = Math.max(globalMaxHeight, actionMaxHeight)
}
// Process each action using the global maximum dimensions
for (const actionData of actionsData) {
if (actionData.sprites.length === 0) continue
// Generate and save the sprite sheet using global dimensions
const frameDimensions = await this.generateAndSaveSpriteSheet(actionData.sprites, sprite.getId(), actionData.action, globalMaxWidth, globalMaxHeight)
if (!frameDimensions) return false
// Create and save sprite action
const spriteAction = new SpriteAction()
spriteAction.setSprite(sprite)
sprite.getSpriteActions().add(spriteAction)
@ -93,73 +120,85 @@ export default class SpriteUpdateEvent extends BaseEvent {
.setSprites(actionData.sprites)
.setOriginX(actionData.originX)
.setOriginY(actionData.originY)
.setFrameWidth(await this.calculateMaxWidth(actionData.sprites))
.setFrameHeight(totalHeight)
.setFrameWidth(frameDimensions.frameWidth)
.setFrameHeight(frameDimensions.frameHeight)
.setFrameRate(actionData.frameRate)
await spriteRepository.getEntityManager().persistAndFlush(spriteAction)
await this.spriteRepository.getEntityManager().persistAndFlush(spriteAction)
}
return callback(true)
return true
} catch (error) {
console.error(`Error updating sprite ${data.id}:`, error)
return callback(false)
console.error('Error processing sprite actions:', error)
return false
}
}
private async generateSpriteSheet(sprites: SpriteImage[], spriteId: string, action: string): Promise<boolean> {
private async generateAndSaveSpriteSheet(sprites: SpriteImage[], spriteId: string, action: string, maxWidth: number, maxHeight: number): Promise<{ frameWidth: number; frameHeight: number } | null> {
try {
if (!sprites.length) return true
if (sprites.length === 0) return { frameWidth: 0, frameHeight: 0 }
// Process all images and get their dimensions
const imageData = await Promise.all(sprites.map((sprite) => this.processImage(sprite)))
const effectiveDimensions = imageData.map((dimensions) => this.calculateEffectiveDimensions(dimensions))
// Extract image data
const imagesData = await Promise.all(
sprites.map(async (sprite) => {
const base64Data = sprite.url.split(';base64,').pop()
if (!base64Data) throw new Error('Invalid base64 image')
const buffer = Buffer.from(base64Data, 'base64')
const metadata = await sharp(buffer).metadata()
// Calculate maximum dimensions
const maxWidth = Math.max(...effectiveDimensions.map((d) => d.width))
const maxHeight = Math.max(...effectiveDimensions.map((d) => d.height))
const maxTop = Math.max(...effectiveDimensions.map((d) => d.top))
const maxBottom = Math.max(...effectiveDimensions.map((d) => d.bottom))
return {
buffer,
width: metadata.width || 0,
height: metadata.height || 0
}
})
)
// Calculate total height needed
const totalHeight = maxHeight + maxTop + maxBottom
// Skip creation if any image has invalid dimensions
if (imagesData.some((data) => data.width === 0 || data.height === 0)) {
console.error('One or more sprites have invalid dimensions')
return null
}
// Process images and create sprite sheet
const processedImages = await Promise.all(
sprites.map(async (sprite, index) => {
const { width, height, offsetX, offsetY } = await this.processImage(sprite)
const uri = sprite.url.split(';base64,').pop()
if (!uri) throw new Error('Invalid base64 image')
const buffer = Buffer.from(uri, 'base64')
// Create frames of uniform size with the original sprites centered
const uniformFrames = await Promise.all(
imagesData.map(async (imageData) => {
// Calculate centering offsets to position the sprite in the middle of the frame
const xOffset = Math.floor((maxWidth - imageData.width) / 2)
const yOffset = Math.floor((maxHeight - imageData.height) / 2)
// Create individual frame
const left = offsetX >= 0 ? offsetX : 0
const verticalOffset = totalHeight - height - (offsetY >= 0 ? offsetY : 0)
// Create a uniform-sized frame with the sprite centered
return sharp({
create: {
width: maxWidth,
height: totalHeight,
height: maxHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.composite([{ input: buffer, left, top: verticalOffset }])
.composite([
{
input: imageData.buffer,
left: xOffset,
top: yOffset
}
])
.png()
.toBuffer()
})
)
// Combine frames into sprite sheet
// Create the sprite sheet with uniform frames
const spriteSheet = await sharp({
create: {
width: maxWidth * sprites.length,
height: totalHeight,
width: maxWidth * uniformFrames.length,
height: maxHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.composite(
processedImages.map((buffer, index) => ({
uniformFrames.map((buffer, index) => ({
input: buffer,
left: index * maxWidth,
top: 0
@ -168,50 +207,19 @@ export default class SpriteUpdateEvent extends BaseEvent {
.png()
.toBuffer()
// Ensure directory exists
// Save sprite sheet
const dir = `public/sprites/${spriteId}`
await fs.promises.mkdir(dir, { recursive: true })
// Save the sprite sheet
await fs.promises.writeFile(`${dir}/${action}.png`, spriteSheet)
return true
// Return the uniform frame dimensions (now global maximum dimensions)
return {
frameWidth: maxWidth,
frameHeight: maxHeight
}
} catch (error) {
console.error('Error generating sprite sheet:', error)
return false
return null
}
}
private async processImage(sprite: SpriteImage): Promise<ImageDimensions> {
const uri = sprite.url.split(';base64,').pop()
if (!uri) throw new Error('Invalid base64 image')
const buffer = Buffer.from(uri, 'base64')
const metadata = await sharp(buffer).metadata()
return {
width: metadata.width ?? 0,
height: metadata.height ?? 0,
offsetX: sprite.offset?.x ?? 0,
offsetY: sprite.offset?.y ?? 0
}
}
private calculateEffectiveDimensions(imageDimensions: ImageDimensions): EffectiveDimensions {
return {
width: imageDimensions.width + Math.abs(imageDimensions.offsetX),
height: imageDimensions.height + Math.abs(imageDimensions.offsetY),
top: imageDimensions.offsetY >= 0 ? imageDimensions.offsetY : 0,
bottom: imageDimensions.offsetY < 0 ? Math.abs(imageDimensions.offsetY) : 0
}
}
private async calculateMaxWidth(sprites: SpriteImage[]): Promise<number> {
if (!sprites.length) return 0
// Process all images and get their dimensions
const imageData = await Promise.all(sprites.map((sprite) => this.processImage(sprite)))
const effectiveDimensions = imageData.map((dimensions) => this.calculateEffectiveDimensions(dimensions))
// Calculate maximum width needed
return Math.max(...effectiveDimensions.map((d) => d.width))
}
}

View File

@ -1,7 +1,7 @@
import { BaseEvent } from '@/application/base/baseEvent'
import { SocketEvent } from '@/application/enums'
import type { UUID } from '@/application/types'
import { Map } from '@/entities/map'
import { type MapEditorMapT } from '@/entities/map'
import MapRepository from '@/repositories/mapRepository'
interface IPayload {
@ -13,7 +13,7 @@ export default class MapRequestEvent extends BaseEvent {
this.socket.on(SocketEvent.GM_MAP_REQUEST, this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Map | null) => void): Promise<void> {
private async handleEvent(data: IPayload, callback: (response: MapEditorMapT | null) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
@ -34,9 +34,7 @@ export default class MapRequestEvent extends BaseEvent {
await mapRepository.getEntityManager().populate(map, mapRepository.POPULATE_MAP_EDITOR as any)
// Remove map.mapEventTiles.teleport.toMap and add map.mapEventTiles.teleport.toMapId
return callback(map)
return callback(await map.mapEditorObject())
} catch (error: any) {
this.logger.error('gm:map:request error', error.message)
return callback(null)

View File

@ -1,7 +1,7 @@
import { BaseEvent } from '@/application/base/baseEvent'
import { MapEventTileType, SocketEvent } from '@/application/enums'
import type { UUID } from '@/application/types'
import { Map } from '@/entities/map'
import { type MapEditorMapT } from '@/entities/map'
import { MapEffect } from '@/entities/mapEffect'
import { MapEventTile } from '@/entities/mapEventTile'
import { MapEventTileTeleport } from '@/entities/mapEventTileTeleport'
@ -21,7 +21,7 @@ interface IPayload {
positionX: number
positionY: number
teleport?: {
toMapId: string
toMap: string
toPositionX: number
toPositionY: number
toRotation: number
@ -36,7 +36,7 @@ export default class MapUpdateEvent extends BaseEvent {
this.socket.on(SocketEvent.GM_MAP_UPDATE, this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Map | null) => void): Promise<void> {
private async handleEvent(data: IPayload, callback: (response: MapEditorMapT | null) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
@ -80,17 +80,17 @@ export default class MapUpdateEvent extends BaseEvent {
map.getMapEffects().removeAll()
// Create and add new map event tiles
for (const tile of data.mapEventTiles) {
const mapEventTile = new MapEventTile().setType(tile.type).setPositionX(tile.positionX).setPositionY(tile.positionY).setMap(map)
if (tile.teleport) {
const toMap = await mapRepository.getById(tile.teleport.toMapId as UUID)
for (const eventTile of data.mapEventTiles) {
const mapEventTile = new MapEventTile().setMap(map).setType(eventTile.type).setPositionX(eventTile.positionX).setPositionY(eventTile.positionY)
if (eventTile.teleport) {
const toMap = await mapRepository.getById(eventTile.teleport.toMap as UUID)
if (!toMap) continue
const teleport = new MapEventTileTeleport()
.setMapEventTile(mapEventTile)
.setToMap(toMap)
.setToPositionX(tile.teleport.toPositionX)
.setToPositionY(tile.teleport.toPositionY)
.setToRotation(tile.teleport.toRotation)
.setToPositionX(eventTile.teleport.toPositionX)
.setToPositionY(eventTile.teleport.toPositionY)
.setToRotation(eventTile.teleport.toRotation)
mapEventTile.setTeleport(teleport)
}
@ -128,9 +128,9 @@ export default class MapUpdateEvent extends BaseEvent {
mapManager.unloadMap(data.mapId)
await mapManager.loadMap(map)
return callback(map)
return callback(await map.mapEditorObject())
} catch (error: any) {
this.emitError(`gm:map:update error: ${error instanceof Error ? error.message + error.stack : String(error)}`)
this.sendNotificationAndLog(`gm:map:update error: ${error instanceof Error ? error.message + error.stack : String(error)}`)
return callback(null)
}
}