From 743d4594df4373bcf71ca16a9f06dbfab637c796 Mon Sep 17 00:00:00 2001 From: Dennis Postma Date: Fri, 20 Dec 2024 23:13:55 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gameMaster/assetManager/sprite/update.ts | 857 ++++++++---------- 1 file changed, 375 insertions(+), 482 deletions(-) diff --git a/src/socketEvents/gameMaster/assetManager/sprite/update.ts b/src/socketEvents/gameMaster/assetManager/sprite/update.ts index c77a549..e7c7e3e 100644 --- a/src/socketEvents/gameMaster/assetManager/sprite/update.ts +++ b/src/socketEvents/gameMaster/assetManager/sprite/update.ts @@ -8,27 +8,88 @@ import { getPublicPath } from '../../../../utilities/storage' import CharacterRepository from '../../../../repositories/characterRepository' import { gameMasterLogger } from '../../../../utilities/logger' -type SpriteActionInput = Omit & { +interface ContentBounds { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +} + +interface IsometricSettings { + tileWidth: number; + tileHeight: number; + centerOffset: number; + 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 + }; +} + +// Types +interface SpriteActionInput extends Omit { sprites: string[] } -type Payload = { +interface Payload { 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: Array<{ - buffer: Buffer - width: number | undefined - height: number | undefined - }> + buffersWithDimensions: ProcessedBuffer[] +} + +interface ProcessedBuffer { + buffer: Buffer + width: number + height: number +} + +interface SpriteDimensions { + width: number + height: number + baselineY: number + contentHeight: number +} + +interface IsometricCenter { + centerX: number + verticalCenterLine: number } 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 @@ -38,491 +99,323 @@ export default class SpriteUpdateEvent { 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) - } - + private async handleSpriteUpdate( + data: Payload, + callback: (success: boolean) => void + ): Promise { try { - const parsedSpriteActions = validateSpriteActions(data.spriteActions) - const processedActions = await processSprites(parsedSpriteActions) + const character = await CharacterRepository.getById(this.socket.characterId!) + if (character?.role !== 'gm') { + return callback(false) + } - await updateDatabase(data.id, data.name, processedActions) - await saveSpritesToDisk(data.id, processedActions) + const parsedActions = this.validateSpriteActions(data.spriteActions) + const processedActions = await this.processSprites(parsedActions) + + await this.updateDatabase(data.id, data.name, processedActions) + await this.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 + private validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] { + try { + const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[] + if (!Array.isArray(parsed)) { + throw new Error('spriteActions is not an array') } - } - - 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 - })) - } - } - }) + return parsed + } catch (error) { + throw new Error(`Invalid sprite actions format: ${error instanceof Error ? error.message : String(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 + }; + }) + ); + + return { + ...action, + frameWidth, + frameHeight, + buffersWithDimensions: processedBuffers + }; + })); + } + + 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 convertSpritesToBuffers(sprites: string[]): Promise { + return Promise.all( + sprites.map(sprite => { + const base64Data = sprite.split(',')[1] + return Buffer.from(base64Data, 'base64') + }) + ) + } + + 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 normalizeIsometricSprite( + buffer: Buffer, + frameWidth: number, + frameHeight: number, + ): Promise { + const analysis = await this.analyzeIsometricSprite(buffer); + + // 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, + height: frameHeight, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 } + } + }) + .composite([{ + input: buffer, + left: adjustedOffset, + top: 0, + }]) + .png() + .toBuffer(); + } + +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 + 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 + ); + + return { + massCenter, + spinePosition, + contentBounds: { + left: leftmost, + right: rightmost, + top: topmost, + bottom: bottommost, + width: rightmost - leftmost + 1, + height: bottommost - topmost + 1 + } + }; + } + + private async saveSpritesToDisk( + id: string, + processedActions: ProcessedSpriteAction[] + ): Promise { + const publicFolder = getPublicPath('sprites', id) + await mkdir(publicFolder, { recursive: true }) + + await Promise.all( + processedActions.map(async (action) => { + const spritesheet = await this.createSpritesheet( + action.buffersWithDimensions, + action.frameWidth, + action.frameHeight + ) + + const filename = getPublicPath('sprites', id, `${action.action}.png`) + await writeFile(filename, spritesheet) + }) + ) + } + + private async createSpritesheet( + frames: ProcessedBuffer[], + frameWidth: number, + frameHeight: number + ): Promise { + // Create background with precise isometric tile width + const background = await sharp({ + create: { + width: this.ISOMETRIC_SETTINGS.tileWidth * frames.length, + height: frameHeight, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 } + } + }).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 + })) + ) + .png() + .toBuffer(); + } + + private async updateDatabase( + id: string, + name: string, + processedActions: 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 + })) + } + } + }) + } +} \ No newline at end of file