1
0
forked from noxious/server

Compare commits

..

2 Commits

Author SHA1 Message Date
743d4594df 🎃 2024-12-20 23:13:55 +01:00
2be49c010f Horror code 2024-12-20 21:29:19 +01:00

View File

@ -8,27 +8,88 @@ import { getPublicPath } from '../../../../utilities/storage'
import CharacterRepository from '../../../../repositories/characterRepository' import CharacterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger' 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[] sprites: string[]
} }
type Payload = { interface Payload {
id: string id: string
name: string name: string
spriteActions: Prisma.JsonValue 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 { interface ProcessedSpriteAction extends SpriteActionInput {
frameWidth: number frameWidth: number
frameHeight: number frameHeight: number
buffersWithDimensions: Array<{ buffersWithDimensions: ProcessedBuffer[]
}
interface ProcessedBuffer {
buffer: Buffer buffer: Buffer
width: number | undefined width: number
height: number | undefined height: number
}> }
interface SpriteDimensions {
width: number
height: number
baselineY: number
contentHeight: number
}
interface IsometricCenter {
centerX: number
verticalCenterLine: number
} }
export default class SpriteUpdateEvent { 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( constructor(
private readonly io: Server, private readonly io: Server,
private readonly socket: TSocket private readonly socket: TSocket
@ -38,191 +99,323 @@ export default class SpriteUpdateEvent {
this.socket.on('gm:sprite:update', this.handleSpriteUpdate.bind(this)) this.socket.on('gm:sprite:update', this.handleSpriteUpdate.bind(this))
} }
private async handleSpriteUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> { private async handleSpriteUpdate(
data: Payload,
callback: (success: boolean) => void
): Promise<void> {
try {
const character = await CharacterRepository.getById(this.socket.characterId!) const character = await CharacterRepository.getById(this.socket.characterId!)
if (character?.role !== 'gm') { if (character?.role !== 'gm') {
return callback(false) return callback(false)
} }
try { const parsedActions = this.validateSpriteActions(data.spriteActions)
const parsedSpriteActions = validateSpriteActions(data.spriteActions) const processedActions = await this.processSprites(parsedActions)
const processedActions = await processSprites(parsedSpriteActions)
await updateDatabase(data.id, data.name, processedActions) await this.updateDatabase(data.id, data.name, processedActions)
await saveSpritesToDisk(data.id, processedActions) await this.saveSpritesToDisk(data.id, processedActions)
callback(true) callback(true)
} catch (error) { } catch (error) {
gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`) gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false) callback(false)
} }
}
function validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] { private validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] {
try { try {
const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[] const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[]
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
gameMasterLogger.error('Error parsing spriteActions: spriteActions is not an array') throw new Error('spriteActions is not an array')
} }
return parsed return parsed
} catch (error) { } catch (error) {
gameMasterLogger.error(`Error parsing spriteActions: ${error instanceof Error ? error.message : String(error)}`) throw new Error(`Invalid sprite actions format: ${error instanceof Error ? error.message : String(error)}`)
throw error
} }
} }
async function processSprites(spriteActions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> { private async processSprites(actions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
return Promise.all( return Promise.all(actions.map(async (action) => {
spriteActions.map(async (spriteAction) => { const spriteBuffers = await this.convertSpritesToBuffers(action.sprites);
const { action, sprites } = spriteAction
if (!Array.isArray(sprites) || sprites.length === 0) { // Analyze first frame to get reference values
gameMasterLogger.error(`Invalid sprites array for action: ${action}`) const frameWidth = this.ISOMETRIC.tileWidth;
} const frameHeight = await this.calculateOptimalHeight(spriteBuffers);
const buffersWithDimensions = await Promise.all( // Process all frames using reference center from first frame
sprites.map(async (sprite: string) => { const processedBuffers = await Promise.all(
const buffer = Buffer.from(sprite.split(',')[1], 'base64') spriteBuffers.map(async (buffer) => {
const normalizedBuffer = await normalizeSprite(buffer) const normalized = await this.normalizeIsometricSprite(
const { width, height } = await sharp(normalizedBuffer).metadata() buffer,
return { buffer: normalizedBuffer, width, height } frameWidth,
}) frameHeight
) );
const frameWidth = Math.max(...buffersWithDimensions.map((b) => b.width || 0))
const frameHeight = Math.max(...buffersWithDimensions.map((b) => b.height || 0))
return { return {
...spriteAction, buffer: normalized,
width: frameWidth,
height: frameHeight
};
})
);
return {
...action,
frameWidth, frameWidth,
frameHeight, frameHeight,
buffersWithDimensions 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')
}) })
) )
} }
async function normalizeSprite(buffer: Buffer): Promise<Buffer> { private calculateMassCenter(density: number[]): number {
const image = sharp(buffer) let totalMass = 0;
let weightedSum = 0;
// Remove any transparent edges density.forEach((mass, position) => {
const trimmed = await image totalMass += mass;
.trim() weightedSum += position * mass;
.toBuffer() });
// Optional: Ensure dimensions are even numbers return totalMass ? Math.round(weightedSum / totalMass) : 0;
const metadata = await sharp(trimmed).metadata()
const width = Math.ceil(metadata.width! / 2) * 2
const height = Math.ceil(metadata.height! / 2) * 2
return sharp(trimmed)
.resize(width, height, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.toBuffer()
} }
async function saveSpritesToDisk(id: string, processedActions: ProcessedSpriteAction[]) { private async normalizeIsometricSprite(
const publicFolder = getPublicPath('sprites', id) buffer: Buffer,
await mkdir(publicFolder, { recursive: true }) frameWidth: number,
frameHeight: number,
): Promise<Buffer> {
const analysis = await this.analyzeIsometricSprite(buffer);
await Promise.all( // Calculate optimal position
processedActions.map(async ({ action, buffersWithDimensions, frameWidth, frameHeight }) => { const idealCenter = Math.floor(frameWidth / 2);
// First pass: analyze all frames to determine the consistent dimensions const offset = idealCenter - analysis.massCenter;
const frames = await Promise.all(
buffersWithDimensions.map(async ({ buffer }) => {
const image = sharp(buffer)
// Get trim boundaries to find actual sprite content // Ensure pixel-perfect alignment
const { info: trimData } = await image const adjustedOffset = Math.round(offset);
.trim()
.toBuffer({ resolveWithObject: true })
// Get original metadata // Create perfectly centered frame
const metadata = await sharp(buffer).metadata() return sharp({
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
}
}
})
)
// 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: { create: {
width: frameWidth * frames.length, width: frameWidth,
height: frameHeight, height: frameHeight,
channels: 4, channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 } background: { r: 0, g: 0, b: 0, alpha: 0 }
} }
}) })
.composite( .composite([{
frames.map(({ buffer, width, height }, index) => { input: buffer,
// Calculate offset to maintain consistent center point left: adjustedOffset,
const frameCenterX = Math.floor(width / 2) top: 0,
const frameCenterY = Math.floor(height / 2) }])
.png()
.toBuffer();
}
const adjustedLeft = index * frameWidth + (frameWidth / 2) - frameCenterX private async findContentBounds(buffer: Buffer) {
const adjustedTop = (frameHeight / 2) - frameCenterY const { data, info } = await sharp(buffer)
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
// Round to nearest even number to prevent sub-pixel rendering const width = info.width;
const left = Math.round(adjustedLeft / 2) * 2 const height = info.height;
const top = 0
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 { return {
input: buffer, width: right - left + 1,
left, height: bottom - top + 1,
top 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() .png()
.toBuffer() .toBuffer();
const filename = getPublicPath('sprites', id, `${action}.png`)
await writeFile(filename, combinedImage)
})
)
} }
async function updateDatabase(id: string, name: string, processedActions: ProcessedSpriteAction[]) { private async updateDatabase(
id: string,
name: string,
processedActions: ProcessedSpriteAction[]
): Promise<void> {
await prisma.sprite.update({ await prisma.sprite.update({
where: { id }, where: { id },
data: { data: {
name, name,
spriteActions: { spriteActions: {
deleteMany: { spriteId: id }, deleteMany: { spriteId: id },
create: processedActions.map(({ action, sprites, originX, originY, isAnimated, isLooping, frameWidth, frameHeight, frameSpeed }) => ({ create: processedActions.map(action => ({
action, action: action.action,
sprites, sprites: action.sprites,
originX, originX: action.originX,
originY, originY: action.originY,
isAnimated, isAnimated: action.isAnimated,
isLooping, isLooping: action.isLooping,
frameWidth, frameWidth: action.frameWidth,
frameHeight, frameHeight: action.frameHeight,
frameSpeed frameSpeed: action.frameSpeed
})) }))
} }
} }
}) })
} }
} }
}