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 preprocessSprite(buffer: Buffer): Promise { // Force the sprite to maintain its exact dimensions return sharp(buffer) .png() .toBuffer(); } async function findContentBounds(buffer: Buffer) { const { data, info } = await sharp(buffer) .raw() .ensureAlpha() .toBuffer({ resolveWithObject: true }); const width = info.width; const height = info.height; // Track pixels per column to find the true center of mass const columnWeights = new Array(width).fill(0); let firstContentY = height; // Track highest content point for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const alpha = data[idx + 3]; if (alpha > 0) { columnWeights[x]++; firstContentY = Math.min(firstContentY, y); } } } // Find the weighted center let totalWeight = 0; let weightedSum = 0; columnWeights.forEach((weight, x) => { if (weight > 0) { totalWeight += weight; weightedSum += x * weight; } }); const centerOfMass = Math.round(weightedSum / totalWeight); return { centerX: centerOfMass, topY: firstContentY }; } async function analyzePixelDistribution(buffer: Buffer) { const { data, info } = await sharp(buffer) .raw() .ensureAlpha() .toBuffer({ resolveWithObject: true }); const width = info.width; const height = info.height; // Track solid pixels in columns and rows const columns = new Array(width).fill(0); const solidPixelsPerRow = new Array(height).fill(0); let firstSolidPixelY = height; // Find the most dense vertical line of pixels for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const alpha = data[idx + 3]; if (alpha > 0) { columns[x]++; solidPixelsPerRow[y]++; firstSolidPixelY = Math.min(firstSolidPixelY, y); } } } // Find the strongest vertical line (most likely the center of the character) let maxDensity = 0; let centralLine = Math.floor(width / 2); for (let x = 0; x < width; x++) { if (columns[x] > maxDensity) { maxDensity = columns[x]; centralLine = x; } } return { centralLine, firstContentY: firstSolidPixelY, densityMap: columns }; } async function processSprites(spriteActions: SpriteActionInput[]): Promise { return Promise.all( spriteActions.map(async (spriteAction) => { const { action, sprites } = spriteAction; // First get all dimensions const spriteBuffers = await Promise.all( sprites.map(async (sprite: string) => { const buffer = Buffer.from(sprite.split(',')[1], 'base64'); const metadata = await sharp(buffer).metadata(); return { buffer, width: metadata.width!, height: metadata.height! }; }) ); // Calculate frame size const frameWidth = Math.ceil(Math.max(...spriteBuffers.map(s => s.width)) / 2) * 2; const frameHeight = Math.ceil(Math.max(...spriteBuffers.map(s => s.height)) / 2) * 2; // Use first frame as reference and center all frames exactly the same way const centerOffset = Math.floor(frameWidth / 2); // Process all sprites with exact same centering const buffersWithDimensions = await Promise.all( spriteBuffers.map(async ({ buffer }) => { const metadata = await sharp(buffer).metadata(); const leftPadding = centerOffset - Math.floor(metadata.width! / 2); return { buffer: await sharp({ create: { width: frameWidth, height: frameHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) .composite([ { input: buffer, left: leftPadding, top: 0 } ]) .png() .toBuffer(), width: frameWidth, height: frameHeight }; }) ); return { ...spriteAction, frameWidth, frameHeight, buffersWithDimensions }; }) ); } // Add these utility functions at the top interface BaselineResult { baselineY: number; contentHeight: number; } async function detectBaseline(buffer: Buffer): Promise { const image = sharp(buffer); const metadata = await image.metadata(); const { data, info } = await image .raw() .ensureAlpha() .toBuffer({ resolveWithObject: true }); const height = metadata.height!; const width = metadata.width!; // Scan from bottom to top to find the lowest non-transparent pixel let baselineY = 0; let topY = height; for (let y = height - 1; y >= 0; y--) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const alpha = data[idx + 3]; if (alpha > 0) { baselineY = Math.max(baselineY, y); topY = Math.min(topY, y); break; } } } return { baselineY, contentHeight: baselineY - topY }; } // Modify the calculateOptimalFrameSize function async function calculateOptimalFrameSize(buffers: Array<{ buffer: Buffer }>) { const roundToEven = (n: number) => Math.ceil(n / 2) * 2; // Analyze all frames const analyses = await Promise.all( buffers.map(async ({ buffer }) => { const metadata = await sharp(buffer).metadata(); const baseline = await detectBaseline(buffer); return { width: metadata.width!, height: metadata.height!, baseline: baseline.baselineY, contentHeight: baseline.contentHeight }; }) ); // Calculate optimal dimensions const maxWidth = roundToEven(Math.max(...analyses.map(a => a.width))); const maxHeight = roundToEven(Math.max(...analyses.map(a => a.height))); // Calculate consistent baseline const maxBaseline = Math.max(...analyses.map(a => a.baseline)); const maxContentHeight = Math.max(...analyses.map(a => a.contentHeight)); return { maxWidth, maxHeight, baselineY: maxBaseline, contentHeight: maxContentHeight }; } async function findSpriteCenter(buffer: Buffer): Promise { const { data, info } = await sharp(buffer) .raw() .ensureAlpha() .toBuffer({ resolveWithObject: true }); const width = info.width; const height = info.height; // For isometric sprites, focus on the upper body area (30-60%) // This helps with more consistent centering especially for downward poses const startY = Math.floor(height * 0.3); const endY = Math.floor(height * 0.6); let leftMost = width; let rightMost = 0; let pixelCount = 0; let weightedSum = 0; // Scan the critical area for solid pixels for (let y = startY; y < endY; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const alpha = data[idx + 3]; if (alpha > 0) { leftMost = Math.min(leftMost, x); rightMost = Math.max(rightMost, x); pixelCount++; weightedSum += x; } } } // Use a combination of mass center and geometric center const massCenterX = Math.round(weightedSum / pixelCount); const geometricCenterX = Math.round((leftMost + rightMost) / 2); // Weighted average favoring the mass center return Math.round((massCenterX * 0.7 + geometricCenterX * 0.3)); } async function findIsometricCenter(buffer: Buffer): Promise<{ centerX: number; verticalCenterLine: number }> { const { data, info } = await sharp(buffer) .raw() .ensureAlpha() .toBuffer({ resolveWithObject: true }); const width = info.width; const height = info.height; // Track solid pixels in vertical slices const verticalSlices = new Array(width).fill(0); // For isometric chars, focus more on upper body (25-65% of height) // This helps with more stable centering especially for downward-facing poses const startY = Math.floor(height * 0.25); const endY = Math.floor(height * 0.65); for (let y = startY; y < endY; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const alpha = data[idx + 3]; if (alpha > 0) { verticalSlices[x]++; } } } // Find the most dense vertical line (likely character's center mass) let maxDensity = 0; let verticalCenterLine = Math.floor(width / 2); for (let x = 0; x < width; x++) { if (verticalSlices[x] > maxDensity) { maxDensity = verticalSlices[x]; verticalCenterLine = x; } } // Find the geometric center of the actual content let leftMost = width; let rightMost = 0; for (let x = 0; x < width; x++) { if (verticalSlices[x] > 0) { leftMost = Math.min(leftMost, x); rightMost = Math.max(rightMost, x); } } // Use a weighted combination of density center and geometric center const geometricCenter = Math.round((leftMost + rightMost) / 2); const centerX = Math.round((verticalCenterLine * 0.7 + geometricCenter * 0.3)); return { centerX, verticalCenterLine }; } async function normalizeIsometricSprite( buffer: Buffer, frameWidth: number, frameHeight: number, targetCenterX: number, isDownwardFacing: boolean = false ): Promise { const metadata = await sharp(buffer).metadata(); const { centerX, verticalCenterLine } = await findIsometricCenter(buffer); // Calculate offset with isometric correction let offset = targetCenterX - centerX; if (isDownwardFacing) { offset = Math.round(offset + (centerX - verticalCenterLine) * 0.5); } // Ensure we don't exceed frame dimensions const leftPadding = Math.max(0, offset); const rightPadding = Math.max(0, frameWidth - metadata.width! - offset); // First ensure the image fits within frame dimensions const resizedBuffer = await sharp(buffer) .resize(Math.min(metadata.width!, frameWidth), Math.min(metadata.height!, frameHeight), { fit: 'inside', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .toBuffer(); // Then apply padding return sharp(resizedBuffer) .extend({ top: 0, bottom: frameHeight - (await sharp(resizedBuffer).metadata()).height!, left: leftPadding, right: rightPadding, background: { r: 0, g: 0, b: 0, alpha: 0 } }) .png() .toBuffer(); } // Modified normalizeSprite function async function normalizeSprite(buffer: Buffer, targetWidth: number, targetHeight: number): Promise { const metadata = await sharp(buffer).metadata(); const currentWidth = metadata.width!; const currentHeight = metadata.height!; // Calculate padding to perfectly center the sprite const leftPadding = Math.floor((targetWidth - currentWidth) / 2); return sharp(buffer) .resize(currentWidth, currentHeight, { fit: 'fill', // Force exact size without any automatic scaling background: { r: 0, g: 0, b: 0, alpha: 0 } }) .extend({ top: 0, bottom: targetHeight - currentHeight, left: leftPadding, right: targetWidth - currentWidth - leftPadding, background: { r: 0, g: 0, b: 0, alpha: 0 } }) .png() .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 }) => { const frames = await Promise.all( buffersWithDimensions.map(async ({ buffer }) => { const metadata = await sharp(buffer).metadata() return { buffer, width: metadata.width!, height: metadata.height! } }) ) 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 }, index) => ({ input: buffer, left: index * frameWidth, // Remove centering calc since sprites are already centered top: 0 })) ) .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 })) } } }) } } }