forked from noxious/server
229 lines
7.8 KiB
TypeScript
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
|
|
}))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|