forked from noxious/server
395 lines
12 KiB
TypeScript
395 lines
12 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'
|
|
|
|
// Constants
|
|
const ISOMETRIC_CONFIG = {
|
|
tileWidth: 64,
|
|
tileHeight: 32,
|
|
centerOffset: 32,
|
|
bodyRatios: {
|
|
topStart: 0.15,
|
|
topEnd: 0.45,
|
|
weightUpper: 0.7,
|
|
weightLower: 0.3
|
|
}
|
|
} as const
|
|
|
|
// Types
|
|
interface ContentBounds {
|
|
left: number
|
|
right: number
|
|
top: number
|
|
bottom: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
interface SpriteActionInput extends Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> {
|
|
sprites: string[]
|
|
}
|
|
|
|
interface UpdatePayload {
|
|
id: string
|
|
name: string
|
|
spriteActions: Prisma.JsonValue
|
|
}
|
|
|
|
interface ProcessedSpriteAction extends SpriteActionInput {
|
|
frameWidth: number
|
|
frameHeight: number
|
|
buffersWithDimensions: ProcessedFrame[]
|
|
}
|
|
|
|
interface ProcessedFrame {
|
|
buffer: Buffer
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
interface SpriteAnalysis {
|
|
massCenter: number
|
|
spinePosition: number
|
|
contentBounds: ContentBounds
|
|
}
|
|
|
|
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(payload: UpdatePayload, callback: (success: boolean) => void): Promise<void> {
|
|
try {
|
|
if (!(await this.validateGameMasterAccess())) {
|
|
return callback(false)
|
|
}
|
|
|
|
const parsedActions = this.validateSpriteActions(payload.spriteActions)
|
|
|
|
// Process sprites
|
|
const processedActions = await Promise.all(parsedActions.map(async (action) => {
|
|
const spriteBuffers = await this.convertBase64ToBuffers(action.sprites)
|
|
const frameWidth = ISOMETRIC_CONFIG.tileWidth
|
|
const frameHeight = await this.calculateOptimalHeight(spriteBuffers)
|
|
const processedFrames = await this.normalizeFrames(spriteBuffers, frameWidth, frameHeight)
|
|
|
|
return {
|
|
...action,
|
|
frameWidth,
|
|
frameHeight,
|
|
buffersWithDimensions: processedFrames
|
|
}
|
|
}))
|
|
|
|
await Promise.all([
|
|
this.updateDatabase(payload.id, payload.name, processedActions),
|
|
this.saveSpritesToDisk(payload.id, processedActions.filter(a => a.buffersWithDimensions.length > 0))
|
|
])
|
|
|
|
callback(true)
|
|
} catch (error) {
|
|
this.handleError(error, payload.id, callback)
|
|
}
|
|
}
|
|
|
|
private async validateGameMasterAccess(): Promise<boolean> {
|
|
const character = await CharacterRepository.getById(this.socket.characterId!)
|
|
return character?.role === 'gm'
|
|
}
|
|
|
|
private validateSpriteActions(actions: Prisma.JsonValue): SpriteActionInput[] {
|
|
try {
|
|
const parsed = JSON.parse(JSON.stringify(actions)) as SpriteActionInput[]
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error('Sprite actions must be an array')
|
|
}
|
|
return parsed
|
|
} catch (error) {
|
|
throw new Error(`Invalid sprite actions format: ${this.getErrorMessage(error)}`)
|
|
}
|
|
}
|
|
|
|
private async convertBase64ToBuffers(sprites: string[]): Promise<Buffer[]> {
|
|
return sprites.map((sprite) => Buffer.from(sprite.split(',')[1], 'base64'))
|
|
}
|
|
|
|
private async normalizeFrames(buffers: Buffer[], frameWidth: number, frameHeight: number): Promise<ProcessedFrame[]> {
|
|
return Promise.all(
|
|
buffers.map(async (buffer) => {
|
|
const normalizedBuffer = await this.normalizeIsometricSprite(buffer, frameWidth, frameHeight)
|
|
return {
|
|
buffer: normalizedBuffer,
|
|
width: frameWidth,
|
|
height: frameHeight
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
private async calculateOptimalHeight(buffers: Buffer[]): Promise<number> {
|
|
if (!buffers.length) return ISOMETRIC_CONFIG.tileHeight // Return default height if no buffers
|
|
|
|
const heights = await Promise.all(
|
|
buffers.map(async (buffer) => {
|
|
const bounds = await this.findContentBounds(buffer)
|
|
return bounds.height
|
|
})
|
|
)
|
|
return Math.ceil(Math.max(...heights) / 2) * 2
|
|
}
|
|
|
|
private async normalizeIsometricSprite(buffer: Buffer, frameWidth: number, frameHeight: number): Promise<Buffer> {
|
|
const analysis = await this.analyzeIsometricSprite(buffer)
|
|
const idealCenter = Math.floor(frameWidth / 2)
|
|
const offset = Math.round(idealCenter - analysis.massCenter)
|
|
|
|
// Process the input sprite
|
|
const processedInput = await sharp(buffer)
|
|
.ensureAlpha()
|
|
.resize({
|
|
width: frameWidth, // Set maximum width
|
|
height: frameHeight, // Set maximum height
|
|
fit: 'inside', // Ensure image fits within dimensions
|
|
kernel: sharp.kernel.nearest,
|
|
position: 'center',
|
|
withoutEnlargement: true // Don't enlarge smaller images
|
|
})
|
|
.png({
|
|
compressionLevel: 9,
|
|
adaptiveFiltering: false,
|
|
palette: true,
|
|
quality: 100,
|
|
colors: 256
|
|
})
|
|
.toBuffer()
|
|
|
|
// Create the final composition
|
|
return sharp({
|
|
create: {
|
|
width: frameWidth,
|
|
height: frameHeight,
|
|
channels: 4,
|
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
}
|
|
})
|
|
.composite([
|
|
{
|
|
input: processedInput,
|
|
left: offset,
|
|
top: 0,
|
|
blend: 'over'
|
|
}
|
|
])
|
|
.png({
|
|
compressionLevel: 9,
|
|
adaptiveFiltering: false,
|
|
palette: true,
|
|
quality: 100,
|
|
colors: 256
|
|
})
|
|
.toBuffer()
|
|
}
|
|
|
|
private async analyzeIsometricSprite(buffer: Buffer): Promise<SpriteAnalysis> {
|
|
const { data, info } = await sharp(buffer).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
|
|
const { width, height } = info
|
|
const upperStart = Math.floor(height * ISOMETRIC_CONFIG.bodyRatios.topStart)
|
|
const upperEnd = Math.floor(height * ISOMETRIC_CONFIG.bodyRatios.topEnd)
|
|
|
|
const { columnDensity, upperBodyDensity, bounds } = this.calculatePixelDistribution(data, width, height, upperStart, upperEnd)
|
|
const spinePosition = this.findSpinePosition(upperBodyDensity)
|
|
const massCenter = this.calculateWeightedMassCenter(columnDensity, upperBodyDensity)
|
|
|
|
return {
|
|
massCenter,
|
|
spinePosition,
|
|
contentBounds: bounds
|
|
}
|
|
}
|
|
|
|
private calculatePixelDistribution(data: Buffer, width: number, height: number, upperStart: number, upperEnd: number) {
|
|
const columnDensity = new Array(width).fill(0)
|
|
const upperBodyDensity = new Array(width).fill(0)
|
|
const bounds = { left: width, right: 0, top: height, bottom: 0 }
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
if (data[(y * width + x) * 4 + 3] > 0) {
|
|
columnDensity[x]++
|
|
if (y >= upperStart && y <= upperEnd) {
|
|
upperBodyDensity[x]++
|
|
}
|
|
this.updateBounds(bounds, x, y)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
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 calculateMassCenter(density: number[]): number {
|
|
const totalMass = density.reduce((sum, mass) => sum + mass, 0)
|
|
if (!totalMass) return 0
|
|
|
|
const weightedSum = density.reduce((sum, mass, position) => sum + position * mass, 0)
|
|
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({
|
|
create: {
|
|
width: ISOMETRIC_CONFIG.tileWidth * frames.length,
|
|
height: frames[0].height,
|
|
channels: 4,
|
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
}
|
|
})
|
|
.png({
|
|
compressionLevel: 9,
|
|
adaptiveFiltering: false,
|
|
palette: true,
|
|
quality: 100,
|
|
colors: 256,
|
|
dither: 0
|
|
})
|
|
.toBuffer()
|
|
|
|
return sharp(background)
|
|
.composite(
|
|
frames.map((frame, index) => ({
|
|
input: frame.buffer,
|
|
left: index * ISOMETRIC_CONFIG.tileWidth,
|
|
top: 0,
|
|
blend: 'over'
|
|
}))
|
|
)
|
|
.png({
|
|
compressionLevel: 9,
|
|
adaptiveFiltering: false,
|
|
palette: true,
|
|
quality: 100,
|
|
colors: 256,
|
|
dither: 0
|
|
})
|
|
.toBuffer()
|
|
}
|
|
|
|
private async updateDatabase(id: string, name: string, actions: ProcessedSpriteAction[]): Promise<void> {
|
|
await prisma.sprite.update({
|
|
where: { id },
|
|
data: {
|
|
name,
|
|
spriteActions: {
|
|
deleteMany: { spriteId: id },
|
|
create: actions.map(this.mapActionToDatabase)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
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,
|
|
frameRate: action.frameRate
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|