Compare commits
5 Commits
feature/#0
...
feature/im
Author | SHA1 | Date | |
---|---|---|---|
bd8caaf27c | |||
3cf86e322c | |||
6f32fbdc79 | |||
743d4594df | |||
2be49c010f |
@ -8,11 +8,34 @@ import { getPublicPath } from '../../../../utilities/storage'
|
||||
import CharacterRepository from '../../../../repositories/characterRepository'
|
||||
import { gameMasterLogger } from '../../../../utilities/logger'
|
||||
|
||||
type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & {
|
||||
// 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[]
|
||||
}
|
||||
|
||||
type Payload = {
|
||||
interface UpdatePayload {
|
||||
id: string
|
||||
name: string
|
||||
spriteActions: Prisma.JsonValue
|
||||
@ -21,11 +44,19 @@ type Payload = {
|
||||
interface ProcessedSpriteAction extends SpriteActionInput {
|
||||
frameWidth: number
|
||||
frameHeight: number
|
||||
buffersWithDimensions: Array<{
|
||||
buffer: Buffer
|
||||
width: number | undefined
|
||||
height: number | undefined
|
||||
}>
|
||||
buffersWithDimensions: ProcessedFrame[]
|
||||
}
|
||||
|
||||
interface ProcessedFrame {
|
||||
buffer: Buffer
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface SpriteAnalysis {
|
||||
massCenter: number
|
||||
spinePosition: number
|
||||
contentBounds: ContentBounds
|
||||
}
|
||||
|
||||
export default class SpriteUpdateEvent {
|
||||
@ -38,191 +69,322 @@ export default class SpriteUpdateEvent {
|
||||
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)
|
||||
}
|
||||
|
||||
private async handleSpriteUpdate(payload: UpdatePayload, callback: (success: boolean) => void): Promise<void> {
|
||||
try {
|
||||
const parsedSpriteActions = validateSpriteActions(data.spriteActions)
|
||||
const processedActions = await processSprites(parsedSpriteActions)
|
||||
if (!await this.validateGameMasterAccess()) {
|
||||
return callback(false)
|
||||
}
|
||||
|
||||
await updateDatabase(data.id, data.name, processedActions)
|
||||
await saveSpritesToDisk(data.id, processedActions)
|
||||
const parsedActions = this.validateSpriteActions(payload.spriteActions)
|
||||
const processedActions = await this.processSprites(parsedActions)
|
||||
|
||||
await Promise.all([
|
||||
this.updateDatabase(payload.id, payload.name, processedActions),
|
||||
this.saveSpritesToDisk(payload.id, processedActions)
|
||||
])
|
||||
|
||||
callback(true)
|
||||
} catch (error) {
|
||||
gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
callback(false)
|
||||
this.handleError(error, payload.id, callback)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
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 processSprites(actions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
|
||||
return Promise.all(actions.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
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
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> {
|
||||
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({
|
||||
kernel: sharp.kernel.nearest,
|
||||
fit: 'contain',
|
||||
position: 'center'
|
||||
})
|
||||
.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 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 = 0
|
||||
|
||||
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
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
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,
|
||||
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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user