1
0
forked from noxious/server

Improved readability

This commit is contained in:
Dennis Postma 2024-12-20 23:36:56 +01:00
parent 743d4594df
commit 6f32fbdc79

View File

@ -8,88 +8,58 @@ 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'
interface ContentBounds { // Constants
left: number; const ISOMETRIC_CONFIG = {
right: number; tileWidth: 64,
top: number; tileHeight: 32,
bottom: number; centerOffset: 32,
width: number;
height: number;
}
interface IsometricSettings {
tileWidth: number;
tileHeight: number;
centerOffset: number;
bodyRatios: { bodyRatios: {
topStart: number; // Where to start analyzing upper body (%) topStart: 0.15,
topEnd: number; // Where to end analyzing upper body (%) topEnd: 0.45,
weightUpper: number; // Weight given to upper body centering weightUpper: 0.7,
weightLower: number; // Weight given to lower body centering weightLower: 0.3
}; }
} } as const
// Types // Types
interface ContentBounds {
left: number
right: number
top: number
bottom: number
width: number
height: number
}
interface SpriteActionInput extends Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> { interface SpriteActionInput extends Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> {
sprites: string[] sprites: string[]
} }
interface Payload { interface UpdatePayload {
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: ProcessedBuffer[] buffersWithDimensions: ProcessedFrame[]
} }
interface ProcessedBuffer { interface ProcessedFrame {
buffer: Buffer buffer: Buffer
width: number width: number
height: number height: number
} }
interface SpriteDimensions { interface SpriteAnalysis {
width: number massCenter: number
height: number spinePosition: number
baselineY: number contentBounds: ContentBounds
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
@ -99,123 +69,91 @@ 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( private async handleSpriteUpdate(payload: UpdatePayload, callback: (success: boolean) => void): Promise<void> {
data: Payload,
callback: (success: boolean) => void
): Promise<void> {
try { try {
const character = await CharacterRepository.getById(this.socket.characterId!) if (!await this.validateGameMasterAccess()) {
if (character?.role !== 'gm') {
return callback(false) return callback(false)
} }
const parsedActions = this.validateSpriteActions(data.spriteActions) const parsedActions = this.validateSpriteActions(payload.spriteActions)
const processedActions = await this.processSprites(parsedActions) const processedActions = await this.processSprites(parsedActions)
await this.updateDatabase(data.id, data.name, processedActions) await Promise.all([
await this.saveSpritesToDisk(data.id, processedActions) this.updateDatabase(payload.id, payload.name, processedActions),
this.saveSpritesToDisk(payload.id, processedActions)
])
callback(true) callback(true)
} catch (error) { } catch (error) {
gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`) this.handleError(error, payload.id, callback)
callback(false)
} }
} }
private validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] { private async validateGameMasterAccess(): Promise<boolean> {
const character = await CharacterRepository.getById(this.socket.characterId!)
return character?.role === 'gm'
}
private validateSpriteActions(actions: Prisma.JsonValue): SpriteActionInput[] {
try { try {
const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[] const parsed = JSON.parse(JSON.stringify(actions)) as SpriteActionInput[]
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
throw new Error('spriteActions is not an array') throw new Error('Sprite actions must be an array')
} }
return parsed return parsed
} catch (error) { } catch (error) {
throw new Error(`Invalid sprite actions format: ${error instanceof Error ? error.message : String(error)}`) throw new Error(`Invalid sprite actions format: ${this.getErrorMessage(error)}`)
} }
} }
private async processSprites(actions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> { private async processSprites(actions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
return Promise.all(actions.map(async (action) => { return Promise.all(actions.map(async (action) => {
const spriteBuffers = await this.convertSpritesToBuffers(action.sprites); const spriteBuffers = await this.convertBase64ToBuffers(action.sprites)
const frameWidth = ISOMETRIC_CONFIG.tileWidth
// Analyze first frame to get reference values const frameHeight = await this.calculateOptimalHeight(spriteBuffers)
const frameWidth = this.ISOMETRIC.tileWidth; const processedFrames = await this.normalizeFrames(spriteBuffers, frameWidth, frameHeight)
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 { return {
...action, ...action,
frameWidth, frameWidth,
frameHeight, frameHeight,
buffersWithDimensions: processedBuffers buffersWithDimensions: processedFrames
}; }
})); }))
} }
private async calculateOptimalHeight(buffers: Buffer[]): Promise<number> { private async convertBase64ToBuffers(sprites: string[]): Promise<Buffer[]> {
const heights = await Promise.all( return sprites.map(sprite => Buffer.from(sprite.split(',')[1], 'base64'))
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[]> { private async normalizeFrames(buffers: Buffer[], frameWidth: number, frameHeight: number): Promise<ProcessedFrame[]> {
return Promise.all( return Promise.all(
sprites.map(sprite => { buffers.map(async (buffer) => {
const base64Data = sprite.split(',')[1] const normalizedBuffer = await this.normalizeIsometricSprite(buffer, frameWidth, frameHeight)
return Buffer.from(base64Data, 'base64') return {
buffer: normalizedBuffer,
width: frameWidth,
height: frameHeight
}
}) })
) )
} }
private calculateMassCenter(density: number[]): number { private async calculateOptimalHeight(buffers: Buffer[]): Promise<number> {
let totalMass = 0; const heights = await Promise.all(
let weightedSum = 0; buffers.map(async buffer => {
const bounds = await this.findContentBounds(buffer)
density.forEach((mass, position) => { return bounds.height
totalMass += mass; })
weightedSum += position * mass; )
}); return Math.ceil(Math.max(...heights) / 2) * 2
return totalMass ? Math.round(weightedSum / totalMass) : 0;
} }
private async normalizeIsometricSprite( private async normalizeIsometricSprite(buffer: Buffer, frameWidth: number, frameHeight: number): Promise<Buffer> {
buffer: Buffer, const analysis = await this.analyzeIsometricSprite(buffer)
frameWidth: number, const idealCenter = Math.floor(frameWidth / 2)
frameHeight: number, const offset = Math.round(idealCenter - analysis.massCenter)
): 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({ return sharp({
create: { create: {
width: frameWidth, width: frameWidth,
@ -224,198 +162,183 @@ export default class SpriteUpdateEvent {
background: { r: 0, g: 0, b: 0, alpha: 0 } background: { r: 0, g: 0, b: 0, alpha: 0 }
} }
}) })
.composite([{ .composite([{ input: buffer, left: offset, top: 0 }])
input: buffer,
left: adjustedOffset,
top: 0,
}])
.png() .png()
.toBuffer(); .toBuffer()
} }
private async findContentBounds(buffer: Buffer) { private async analyzeIsometricSprite(buffer: Buffer): Promise<SpriteAnalysis> {
const { data, info } = await sharp(buffer) const { data, info } = await sharp(buffer).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
.raw() const { width, height } = info
.ensureAlpha() const upperStart = Math.floor(height * ISOMETRIC_CONFIG.bodyRatios.topStart)
.toBuffer({ resolveWithObject: true }); const upperEnd = Math.floor(height * ISOMETRIC_CONFIG.bodyRatios.topEnd)
const width = info.width; const { columnDensity, upperBodyDensity, bounds } = this.calculatePixelDistribution(data, width, height, upperStart, upperEnd)
const height = info.height; const spinePosition = this.findSpinePosition(upperBodyDensity)
const massCenter = this.calculateWeightedMassCenter(columnDensity, upperBodyDensity)
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 { return {
massCenter, massCenter,
spinePosition, spinePosition,
contentBounds: { contentBounds: bounds
left: leftmost, }
right: rightmost,
top: topmost,
bottom: bottommost,
width: rightmost - leftmost + 1,
height: bottommost - topmost + 1
}
};
} }
private async saveSpritesToDisk( private calculatePixelDistribution(data: Buffer, width: number, height: number, upperStart: number, upperEnd: number) {
id: string, const columnDensity = new Array(width).fill(0)
processedActions: ProcessedSpriteAction[] const upperBodyDensity = new Array(width).fill(0)
): Promise<void> { const bounds = { left: width, right: 0, top: height, bottom: 0 }
const publicFolder = getPublicPath('sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all( for (let y = 0; y < height; y++) {
processedActions.map(async (action) => { for (let x = 0; x < width; x++) {
const spritesheet = await this.createSpritesheet( if (data[(y * width + x) * 4 + 3] > 0) {
action.buffersWithDimensions, columnDensity[x]++
action.frameWidth, if (y >= upperStart && y <= upperEnd) {
action.frameHeight upperBodyDensity[x]++
) }
this.updateBounds(bounds, x, y)
}
}
}
const filename = getPublicPath('sprites', id, `${action.action}.png`) return {
await writeFile(filename, spritesheet) columnDensity,
}) upperBodyDensity,
bounds: {
...bounds,
width: bounds.right - bounds.left + 1,
height: bounds.bottom - bounds.top + 1
}
}
}
private updateBounds(bounds: { left: number; right: number; top: number; bottom: number }, x: number, y: number): void {
bounds.left = Math.min(bounds.left, x)
bounds.right = Math.max(bounds.right, x)
bounds.top = Math.min(bounds.top, y)
bounds.bottom = Math.max(bounds.bottom, y)
}
private findSpinePosition(density: number[]): number {
return density.reduce((maxIdx, curr, idx, arr) => curr > arr[maxIdx] ? idx : maxIdx, 0)
}
private calculateWeightedMassCenter(columnDensity: number[], upperBodyDensity: number[]): number {
const upperMassCenter = this.calculateMassCenter(upperBodyDensity)
const lowerMassCenter = this.calculateMassCenter(columnDensity)
return Math.round(
upperMassCenter * ISOMETRIC_CONFIG.bodyRatios.weightUpper +
lowerMassCenter * ISOMETRIC_CONFIG.bodyRatios.weightLower
) )
} }
private async createSpritesheet( private calculateMassCenter(density: number[]): number {
frames: ProcessedBuffer[], const totalMass = density.reduce((sum, mass) => sum + mass, 0)
frameWidth: number, if (!totalMass) return 0
frameHeight: number
): Promise<Buffer> { const weightedSum = density.reduce((sum, mass, position) => sum + position * mass, 0)
// Create background with precise isometric tile width return Math.round(weightedSum / totalMass)
}
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 by checking alpha channel
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 saveSpritesToDisk(id: string, actions: ProcessedSpriteAction[]): Promise<void> {
const publicFolder = getPublicPath('sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all(actions.map(async (action) => {
const spritesheet = await this.createSpritesheet(action.buffersWithDimensions)
await writeFile(getPublicPath('sprites', id, `${action.action}.png`), spritesheet)
}))
}
private async createSpritesheet(frames: ProcessedFrame[]): Promise<Buffer> {
const background = await sharp({ const background = await sharp({
create: { create: {
width: this.ISOMETRIC_SETTINGS.tileWidth * frames.length, width: ISOMETRIC_CONFIG.tileWidth * frames.length,
height: frameHeight, height: frames[0].height,
channels: 4, channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 } background: { r: 0, g: 0, b: 0, alpha: 0 }
} }
}).png().toBuffer(); }).png().toBuffer()
// Composite frames with exact tile-based positioning
return sharp(background) return sharp(background)
.composite( .composite(frames.map((frame, index) => ({
frames.map((frame, index) => ({ input: frame.buffer,
input: frame.buffer, left: index * ISOMETRIC_CONFIG.tileWidth,
left: index * this.ISOMETRIC_SETTINGS.tileWidth, top: 0
top: 0 })))
}))
)
.png() .png()
.toBuffer(); .toBuffer()
} }
private async updateDatabase( private async updateDatabase(id: string, name: string, actions: ProcessedSpriteAction[]): Promise<void> {
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 => ({ create: actions.map(this.mapActionToDatabase)
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
}))
} }
} }
}) })
} }
private mapActionToDatabase(action: ProcessedSpriteAction) {
return {
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
}
}
private handleError(error: unknown, spriteId: string, callback: (success: boolean) => void): void {
gameMasterLogger.error(`Error updating sprite ${spriteId}: ${this.getErrorMessage(error)}`)
callback(false)
}
private getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
} }