1
0
forked from noxious/server

229 lines
7.8 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 processSprites(spriteActions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
return Promise.all(
spriteActions.map(async (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(
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 frameWidth = Math.max(...buffersWithDimensions.map((b) => b.width || 0))
const frameHeight = Math.max(...buffersWithDimensions.map((b) => b.height || 0))
return {
...spriteAction,
frameWidth,
frameHeight,
buffersWithDimensions
}
})
)
}
async function normalizeSprite(buffer: Buffer): Promise<Buffer> {
const image = sharp(buffer)
// Remove any transparent edges
const trimmed = await image
.trim()
.toBuffer()
// 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
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[]) {
const publicFolder = getPublicPath('sprites', id)
await mkdir(publicFolder, { recursive: true })
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
}
}
})
)
// 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,
height: frameHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.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 = Math.round(adjustedTop / 2) * 2
return {
input: buffer,
left,
top
}
})
)
.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
}))
}
}
})
}
}
}