diff --git a/src/socketEvents/gameMaster/assetManager/sprite/update.ts b/src/socketEvents/gameMaster/assetManager/sprite/update.ts index e7c7e3e..0d3a6f2 100644 --- a/src/socketEvents/gameMaster/assetManager/sprite/update.ts +++ b/src/socketEvents/gameMaster/assetManager/sprite/update.ts @@ -8,88 +8,58 @@ import { getPublicPath } from '../../../../utilities/storage' import CharacterRepository from '../../../../repositories/characterRepository' import { gameMasterLogger } from '../../../../utilities/logger' -interface ContentBounds { - left: number; - right: number; - top: number; - bottom: number; - width: number; - height: number; -} - -interface IsometricSettings { - tileWidth: number; - tileHeight: number; - centerOffset: number; +// Constants +const ISOMETRIC_CONFIG = { + tileWidth: 64, + tileHeight: 32, + centerOffset: 32, bodyRatios: { - topStart: number; // Where to start analyzing upper body (%) - topEnd: number; // Where to end analyzing upper body (%) - weightUpper: number; // Weight given to upper body centering - weightLower: number; // Weight given to lower body centering - }; -} + 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 Payload { +interface UpdatePayload { id: string name: string spriteActions: Prisma.JsonValue } -interface IsometricGrid { - tileWidth: number; // Standard isometric tile width (typically 64px) - tileHeight: number; // Standard isometric tile height (typically 32px) - centerOffset: number; // Center offset for proper tile alignment -} - interface ProcessedSpriteAction extends SpriteActionInput { frameWidth: number frameHeight: number - buffersWithDimensions: ProcessedBuffer[] + buffersWithDimensions: ProcessedFrame[] } -interface ProcessedBuffer { +interface ProcessedFrame { buffer: Buffer width: number height: number } -interface SpriteDimensions { - width: number - height: number - baselineY: number - contentHeight: number -} - -interface IsometricCenter { - centerX: number - verticalCenterLine: number +interface SpriteAnalysis { + massCenter: number + spinePosition: number + contentBounds: ContentBounds } export default class SpriteUpdateEvent { - - private readonly ISOMETRIC = { - tileWidth: 64, - tileHeight: 32, - centerOffset: 32, - bodyRatios: { - topStart: 0.15, // Start at 15% from top - topEnd: 0.45, // End at 45% from top - weightUpper: 0.7, // 70% weight to upper body - weightLower: 0.3 // 30% weight to lower body - } - } as const; - - private readonly ISOMETRIC_SETTINGS: IsometricGrid = { - tileWidth: 64, // Habbo-style standard tile width - tileHeight: 32, // Habbo-style standard tile height - centerOffset: 32 // Center point of the tile - }; - constructor( private readonly io: Server, private readonly socket: TSocket @@ -99,123 +69,91 @@ export default class SpriteUpdateEvent { this.socket.on('gm:sprite:update', this.handleSpriteUpdate.bind(this)) } - private async handleSpriteUpdate( - data: Payload, - callback: (success: boolean) => void - ): Promise { + private async handleSpriteUpdate(payload: UpdatePayload, callback: (success: boolean) => void): Promise { try { - const character = await CharacterRepository.getById(this.socket.characterId!) - if (character?.role !== 'gm') { + if (!await this.validateGameMasterAccess()) { return callback(false) } - const parsedActions = this.validateSpriteActions(data.spriteActions) + const parsedActions = this.validateSpriteActions(payload.spriteActions) const processedActions = await this.processSprites(parsedActions) - await this.updateDatabase(data.id, data.name, processedActions) - await this.saveSpritesToDisk(data.id, processedActions) + await Promise.all([ + this.updateDatabase(payload.id, payload.name, processedActions), + this.saveSpritesToDisk(payload.id, processedActions) + ]) callback(true) } catch (error) { - gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`) - callback(false) + this.handleError(error, payload.id, callback) } } - private validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] { + 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(spriteActions)) as SpriteActionInput[] + const parsed = JSON.parse(JSON.stringify(actions)) as SpriteActionInput[] if (!Array.isArray(parsed)) { - throw new Error('spriteActions is not an array') + throw new Error('Sprite actions must be an array') } return parsed } catch (error) { - throw new Error(`Invalid sprite actions format: ${error instanceof Error ? error.message : String(error)}`) + throw new Error(`Invalid sprite actions format: ${this.getErrorMessage(error)}`) } } private async processSprites(actions: SpriteActionInput[]): Promise { return Promise.all(actions.map(async (action) => { - const spriteBuffers = await this.convertSpritesToBuffers(action.sprites); - - // Analyze first frame to get reference values - const frameWidth = this.ISOMETRIC.tileWidth; - const frameHeight = await this.calculateOptimalHeight(spriteBuffers); - - // Process all frames using reference center from first frame - const processedBuffers = await Promise.all( - spriteBuffers.map(async (buffer) => { - const normalized = await this.normalizeIsometricSprite( - buffer, - frameWidth, - frameHeight - ); - - return { - buffer: normalized, - width: frameWidth, - height: frameHeight - }; - }) - ); + 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: processedBuffers - }; - })); + buffersWithDimensions: processedFrames + } + })) } - private async calculateOptimalHeight(buffers: Buffer[]): Promise { - const heights = await Promise.all( - buffers.map(async (buffer) => { - const bounds = await this.findContentBounds(buffer); - return bounds.height; - }) - ); - - // Ensure height is even for perfect pixel alignment - return Math.ceil(Math.max(...heights) / 2) * 2; + private async convertBase64ToBuffers(sprites: string[]): Promise { + return sprites.map(sprite => Buffer.from(sprite.split(',')[1], 'base64')) } - private async convertSpritesToBuffers(sprites: string[]): Promise { + private async normalizeFrames(buffers: Buffer[], frameWidth: number, frameHeight: number): Promise { return Promise.all( - sprites.map(sprite => { - const base64Data = sprite.split(',')[1] - return Buffer.from(base64Data, 'base64') + buffers.map(async (buffer) => { + const normalizedBuffer = await this.normalizeIsometricSprite(buffer, frameWidth, frameHeight) + return { + buffer: normalizedBuffer, + width: frameWidth, + height: frameHeight + } }) ) } - private calculateMassCenter(density: number[]): number { - let totalMass = 0; - let weightedSum = 0; - - density.forEach((mass, position) => { - totalMass += mass; - weightedSum += position * mass; - }); - - return totalMass ? Math.round(weightedSum / totalMass) : 0; + private async calculateOptimalHeight(buffers: Buffer[]): Promise { + 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); + 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) - // Calculate optimal position - const idealCenter = Math.floor(frameWidth / 2); - const offset = idealCenter - analysis.massCenter; - - // Ensure pixel-perfect alignment - const adjustedOffset = Math.round(offset); - - // Create perfectly centered frame return sharp({ create: { width: frameWidth, @@ -224,198 +162,183 @@ export default class SpriteUpdateEvent { background: { r: 0, g: 0, b: 0, alpha: 0 } } }) - .composite([{ - input: buffer, - left: adjustedOffset, - top: 0, - }]) + .composite([{ input: buffer, left: offset, top: 0 }]) .png() - .toBuffer(); + .toBuffer() } -private async findContentBounds(buffer: Buffer) { - const { data, info } = await sharp(buffer) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); + 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 width = info.width; - const height = info.height; - - let left = width; - let right = 0; - let top = height; - let bottom = 0; - - // Find actual content boundaries - 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 analyzeIsometricSprite(buffer: Buffer): Promise<{ - massCenter: number; - spinePosition: number; - contentBounds: ContentBounds; - }> { - const { data, info } = await sharp(buffer) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); - - const width = info.width; - const height = info.height; - - // Separate analysis for upper and lower body - const upperStart = Math.floor(height * this.ISOMETRIC.bodyRatios.topStart); - const upperEnd = Math.floor(height * this.ISOMETRIC.bodyRatios.topEnd); - - const columnDensity = new Array(width).fill(0); - const upperBodyDensity = new Array(width).fill(0); - let leftmost = width; - let rightmost = 0; - let topmost = height; - let bottommost = 0; - - // Analyze pixel distribution - 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) { - columnDensity[x]++; - if (y >= upperStart && y <= upperEnd) { - upperBodyDensity[x]++; - } - - leftmost = Math.min(leftmost, x); - rightmost = Math.max(rightmost, x); - topmost = Math.min(topmost, y); - bottommost = Math.max(bottommost, y); - } - } - } - - // Find spine (densest vertical line in upper body) - let maxDensity = 0; - let spinePosition = 0; - for (let x = 0; x < width; x++) { - if (upperBodyDensity[x] > maxDensity) { - maxDensity = upperBodyDensity[x]; - spinePosition = x; - } - } - - // Calculate weighted mass center - const upperMassCenter = this.calculateMassCenter(upperBodyDensity); - const lowerMassCenter = this.calculateMassCenter(columnDensity); - - const massCenter = Math.round( - upperMassCenter * this.ISOMETRIC.bodyRatios.weightUpper + - lowerMassCenter * this.ISOMETRIC.bodyRatios.weightLower - ); + 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: { - left: leftmost, - right: rightmost, - top: topmost, - bottom: bottommost, - width: rightmost - leftmost + 1, - height: bottommost - topmost + 1 - } - }; + contentBounds: bounds + } } - private async saveSpritesToDisk( - id: string, - processedActions: ProcessedSpriteAction[] - ): Promise { - const publicFolder = getPublicPath('sprites', id) - await mkdir(publicFolder, { recursive: true }) + 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 } - await Promise.all( - processedActions.map(async (action) => { - const spritesheet = await this.createSpritesheet( - action.buffersWithDimensions, - action.frameWidth, - action.frameHeight - ) + 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) + } + } + } - const filename = getPublicPath('sprites', id, `${action.action}.png`) - await writeFile(filename, spritesheet) - }) + 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 async createSpritesheet( - frames: ProcessedBuffer[], - frameWidth: number, - frameHeight: number - ): Promise { - // Create background with precise isometric tile width + 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: this.ISOMETRIC_SETTINGS.tileWidth * frames.length, - height: frameHeight, + width: ISOMETRIC_CONFIG.tileWidth * frames.length, + height: frames[0].height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } - }).png().toBuffer(); + }).png().toBuffer() - // Composite frames with exact tile-based positioning return sharp(background) - .composite( - frames.map((frame, index) => ({ - input: frame.buffer, - left: index * this.ISOMETRIC_SETTINGS.tileWidth, - top: 0 - })) - ) + .composite(frames.map((frame, index) => ({ + input: frame.buffer, + left: index * ISOMETRIC_CONFIG.tileWidth, + top: 0 + }))) .png() - .toBuffer(); + .toBuffer() } - private async updateDatabase( - id: string, - name: string, - processedActions: ProcessedSpriteAction[] - ): Promise { + private async updateDatabase(id: string, name: string, actions: ProcessedSpriteAction[]): Promise { await prisma.sprite.update({ where: { id }, data: { name, spriteActions: { deleteMany: { spriteId: id }, - create: processedActions.map(action => ({ - action: action.action, - sprites: action.sprites, - originX: action.originX, - originY: action.originY, - isAnimated: action.isAnimated, - isLooping: action.isLooping, - frameWidth: action.frameWidth, - frameHeight: action.frameHeight, - frameSpeed: action.frameSpeed - })) + 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, + frameSpeed: action.frameSpeed + } + } + + 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) + } } \ No newline at end of file