import { Server } from 'socket.io' import { TSocket } from '../../../../utilities/types' import prisma from '../../../../utilities/prisma' import type { Prisma, SpriteAction } from '@prisma/client' import { writeFile, mkdir } from 'node:fs/promises' import sharp from 'sharp' import { getPublicPath } from '../../../../utilities/storage' import CharacterRepository from '../../../../repositories/characterRepository' import { gameMasterLogger } from '../../../../utilities/logger' type SpriteActionInput = Omit & { sprites: string[] } type Payload = { id: string name: string spriteActions: Prisma.JsonValue } interface ProcessedSpriteAction extends SpriteActionInput { frameWidth: number frameHeight: number buffersWithDimensions: Array<{ buffer: Buffer width: number | undefined height: number | undefined }> } export default class SpriteUpdateEvent { constructor( private readonly io: Server, private readonly socket: TSocket ) {} public listen(): void { this.socket.on('gm:sprite:update', this.handleSpriteUpdate.bind(this)) } private async handleSpriteUpdate(data: Payload, callback: (success: boolean) => void): Promise { const character = await CharacterRepository.getById(this.socket.characterId!) if (character?.role !== 'gm') { return callback(false) } try { const parsedSpriteActions = validateSpriteActions(data.spriteActions) const processedActions = await processSprites(parsedSpriteActions) await updateDatabase(data.id, data.name, processedActions) await saveSpritesToDisk(data.id, processedActions) callback(true) } catch (error) { gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`) callback(false) } function validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] { try { const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[] if (!Array.isArray(parsed)) { gameMasterLogger.error('Error parsing spriteActions: spriteActions is not an array') } return parsed } catch (error) { gameMasterLogger.error(`Error parsing spriteActions: ${error instanceof Error ? error.message : String(error)}`) throw error } } async function processSprites(spriteActions: SpriteActionInput[]): Promise { return Promise.all( spriteActions.map(async (spriteAction) => { const { action, sprites } = spriteAction if (!Array.isArray(sprites) || sprites.length === 0) { gameMasterLogger.error(`Invalid sprites array for action: ${action}`) } const buffersWithDimensions = await Promise.all( sprites.map(async (sprite: string) => { const buffer = Buffer.from(sprite.split(',')[1], 'base64') const normalizedBuffer = await normalizeSprite(buffer) const { width, height } = await sharp(normalizedBuffer).metadata() return { buffer: normalizedBuffer, width, height } }) ) const frameWidth = Math.max(...buffersWithDimensions.map((b) => b.width || 0)) const frameHeight = Math.max(...buffersWithDimensions.map((b) => b.height || 0)) return { ...spriteAction, frameWidth, frameHeight, buffersWithDimensions } }) ) } async function normalizeSprite(buffer: Buffer): Promise { const image = sharp(buffer) // Remove any transparent edges const trimmed = await image .trim() .toBuffer() // Optional: Ensure dimensions are even numbers const metadata = await sharp(trimmed).metadata() const width = Math.ceil(metadata.width! / 2) * 2 const height = Math.ceil(metadata.height! / 2) * 2 return sharp(trimmed) .resize(width, height, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .toBuffer() } async function saveSpritesToDisk(id: string, processedActions: ProcessedSpriteAction[]) { const publicFolder = getPublicPath('sprites', id) await mkdir(publicFolder, { recursive: true }) await Promise.all( processedActions.map(async ({ action, buffersWithDimensions, frameWidth, frameHeight }) => { // First pass: analyze all frames to determine the consistent dimensions const frames = await Promise.all( buffersWithDimensions.map(async ({ buffer }) => { const image = sharp(buffer) // Get trim boundaries to find actual sprite content const { info: trimData } = await image .trim() .toBuffer({ resolveWithObject: true }) // Get original metadata const metadata = await sharp(buffer).metadata() return { buffer, width: metadata.width!, height: metadata.height!, trimmed: { width: trimData.width, height: trimData.height, left: metadata.width! - trimData.width, top: metadata.height! - trimData.height } } }) ) // Calculate the average center point of all frames const centerPoints = frames.map(frame => ({ x: Math.floor(frame.trimmed.left + frame.trimmed.width / 2), y: Math.floor(frame.trimmed.top + frame.trimmed.height / 2) })) const avgCenterX = Math.round(centerPoints.reduce((sum, p) => sum + p.x, 0) / frames.length) const avgCenterY = Math.round(centerPoints.reduce((sum, p) => sum + p.y, 0) / frames.length) // Create the combined image with precise alignment const combinedImage = await sharp({ create: { width: frameWidth * frames.length, height: frameHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) .composite( frames.map(({ buffer, width, height }, index) => { // Calculate offset to maintain consistent center point const frameCenterX = Math.floor(width / 2) const frameCenterY = Math.floor(height / 2) const adjustedLeft = index * frameWidth + (frameWidth / 2) - frameCenterX const adjustedTop = (frameHeight / 2) - frameCenterY // Round to nearest even number to prevent sub-pixel rendering const left = Math.round(adjustedLeft / 2) * 2 const top = Math.round(adjustedTop / 2) * 2 return { input: buffer, left, top } }) ) .png() .toBuffer() const filename = getPublicPath('sprites', id, `${action}.png`) await writeFile(filename, combinedImage) }) ) } async function updateDatabase(id: string, name: string, processedActions: ProcessedSpriteAction[]) { await prisma.sprite.update({ where: { id }, data: { name, spriteActions: { deleteMany: { spriteId: id }, create: processedActions.map(({ action, sprites, originX, originY, isAnimated, isLooping, frameWidth, frameHeight, frameSpeed }) => ({ action, sprites, originX, originY, isAnimated, isLooping, frameWidth, frameHeight, frameSpeed })) } } }) } } }