diff --git a/src/socketEvents/gameMaster/assetManager/sprite/update.ts b/src/socketEvents/gameMaster/assetManager/sprite/update.ts index b152ad9..c77a549 100644 --- a/src/socketEvents/gameMaster/assetManager/sprite/update.ts +++ b/src/socketEvents/gameMaster/assetManager/sprite/update.ts @@ -70,56 +70,395 @@ export default class SpriteUpdateEvent { } } + 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 + const { action, sprites } = spriteAction; - if (!Array.isArray(sprites) || sprites.length === 0) { - gameMasterLogger.error(`Invalid sprites array for action: ${action}`) - } - - const buffersWithDimensions = await Promise.all( + // First get all dimensions + const spriteBuffers = 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 buffer = Buffer.from(sprite.split(',')[1], 'base64'); + const metadata = await sharp(buffer).metadata(); + return { buffer, width: metadata.width!, height: metadata.height! }; }) - ) + ); - const frameWidth = Math.max(...buffersWithDimensions.map((b) => b.width || 0)) - const frameHeight = Math.max(...buffersWithDimensions.map((b) => b.height || 0)) + // 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 - } + }; }) - ) + ); } - async function normalizeSprite(buffer: Buffer): Promise { - const image = sharp(buffer) + // Add these utility functions at the top + interface BaselineResult { + baselineY: number; + contentHeight: number; + } - // Remove any transparent edges - const trimmed = await image - .trim() - .toBuffer() + 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 }); - // 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 + const height = metadata.height!; + const width = metadata.width!; - return sharp(trimmed) - .resize(width, height, { - fit: 'contain', + // 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() + .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[]) { @@ -128,43 +467,17 @@ export default class SpriteUpdateEvent { 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 - } + height: metadata.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, @@ -174,27 +487,14 @@ export default class SpriteUpdateEvent { } }) .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 = 0 - - return { - input: buffer, - left, - top - } - }) + frames.map(({ buffer }, index) => ({ + input: buffer, + left: index * frameWidth, // Remove centering calc since sprites are already centered + top: 0 + })) ) .png() - .toBuffer() + .toBuffer(); const filename = getPublicPath('sprites', id, `${action}.png`) await writeFile(filename, combinedImage)