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 { 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.handleSpriteUpdate.bind(this)) } private async handleSpriteUpdate(payload: UpdatePayload, callback: (success: boolean) => void): Promise { 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 { 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 { return sprites.map((sprite) => Buffer.from(sprite.split(',')[1], 'base64')) } private async normalizeFrames(buffers: Buffer[], frameWidth: number, frameHeight: number): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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) } }