1
0
forked from noxious/server
2024-12-20 21:29:19 +01:00

529 lines
17 KiB
TypeScript

import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import type { Prisma, SpriteAction } from '@prisma/client'
import { writeFile, mkdir } from 'node:fs/promises'
import sharp from 'sharp'
import { getPublicPath } from '../../../../utilities/storage'
import CharacterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & {
sprites: string[]
}
type Payload = {
id: string
name: string
spriteActions: Prisma.JsonValue
}
interface ProcessedSpriteAction extends SpriteActionInput {
frameWidth: number
frameHeight: number
buffersWithDimensions: Array<{
buffer: Buffer
width: number | undefined
height: number | undefined
}>
}
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(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await CharacterRepository.getById(this.socket.characterId!)
if (character?.role !== 'gm') {
return callback(false)
}
try {
const parsedSpriteActions = validateSpriteActions(data.spriteActions)
const processedActions = await processSprites(parsedSpriteActions)
await updateDatabase(data.id, data.name, processedActions)
await 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
}
}
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
}))
}
}
})
}
}
}