forked from noxious/server
🎃
This commit is contained in:
parent
2be49c010f
commit
743d4594df
@ -8,27 +8,88 @@ import { getPublicPath } from '../../../../utilities/storage'
|
||||
import CharacterRepository from '../../../../repositories/characterRepository'
|
||||
import { gameMasterLogger } from '../../../../utilities/logger'
|
||||
|
||||
type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & {
|
||||
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<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Buffer> {
|
||||
// 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<ProcessedSpriteAction[]> {
|
||||
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<BaselineResult> {
|
||||
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<number> {
|
||||
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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<ProcessedSpriteAction[]> {
|
||||
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<number> {
|
||||
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<Buffer[]> {
|
||||
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<Buffer> {
|
||||
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<void> {
|
||||
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<Buffer> {
|
||||
// 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<void> {
|
||||
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
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user