forked from noxious/server
Compare commits
2 Commits
feature/#0
...
feature/#2
Author | SHA1 | Date | |
---|---|---|---|
743d4594df | |||
2be49c010f |
@ -8,27 +8,88 @@ 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'
|
||||||
|
|
||||||
type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & {
|
interface ContentBounds {
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IsometricSettings {
|
||||||
|
tileWidth: number;
|
||||||
|
tileHeight: number;
|
||||||
|
centerOffset: number;
|
||||||
|
bodyRatios: {
|
||||||
|
topStart: number; // Where to start analyzing upper body (%)
|
||||||
|
topEnd: number; // Where to end analyzing upper body (%)
|
||||||
|
weightUpper: number; // Weight given to upper body centering
|
||||||
|
weightLower: number; // Weight given to lower body centering
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface SpriteActionInput extends Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> {
|
||||||
sprites: string[]
|
sprites: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Payload = {
|
interface Payload {
|
||||||
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: Array<{
|
buffersWithDimensions: ProcessedBuffer[]
|
||||||
buffer: Buffer
|
}
|
||||||
width: number | undefined
|
|
||||||
height: number | undefined
|
interface ProcessedBuffer {
|
||||||
}>
|
buffer: Buffer
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpriteDimensions {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
baselineY: number
|
||||||
|
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
|
||||||
@ -38,191 +99,323 @@ 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(data: Payload, callback: (success: boolean) => void): Promise<void> {
|
private async handleSpriteUpdate(
|
||||||
const character = await CharacterRepository.getById(this.socket.characterId!)
|
data: Payload,
|
||||||
if (character?.role !== 'gm') {
|
callback: (success: boolean) => void
|
||||||
return callback(false)
|
): Promise<void> {
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedSpriteActions = validateSpriteActions(data.spriteActions)
|
const character = await CharacterRepository.getById(this.socket.characterId!)
|
||||||
const processedActions = await processSprites(parsedSpriteActions)
|
if (character?.role !== 'gm') {
|
||||||
|
return callback(false)
|
||||||
|
}
|
||||||
|
|
||||||
await updateDatabase(data.id, data.name, processedActions)
|
const parsedActions = this.validateSpriteActions(data.spriteActions)
|
||||||
await saveSpritesToDisk(data.id, processedActions)
|
const processedActions = await this.processSprites(parsedActions)
|
||||||
|
|
||||||
|
await this.updateDatabase(data.id, data.name, processedActions)
|
||||||
|
await this.saveSpritesToDisk(data.id, processedActions)
|
||||||
|
|
||||||
callback(true)
|
callback(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
|
gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
callback(false)
|
callback(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] {
|
private validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[]
|
const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[]
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
gameMasterLogger.error('Error parsing spriteActions: spriteActions is not an array')
|
throw new Error('spriteActions is not an array')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid sprite actions format: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processSprites(actions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
|
||||||
|
return Promise.all(actions.map(async (action) => {
|
||||||
|
const spriteBuffers = await this.convertSpritesToBuffers(action.sprites);
|
||||||
|
|
||||||
|
// Analyze first frame to get reference values
|
||||||
|
const frameWidth = this.ISOMETRIC.tileWidth;
|
||||||
|
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 {
|
||||||
|
...action,
|
||||||
|
frameWidth,
|
||||||
|
frameHeight,
|
||||||
|
buffersWithDimensions: processedBuffers
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure height is even for perfect pixel alignment
|
||||||
|
return Math.ceil(Math.max(...heights) / 2) * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async convertSpritesToBuffers(sprites: string[]): Promise<Buffer[]> {
|
||||||
|
return Promise.all(
|
||||||
|
sprites.map(sprite => {
|
||||||
|
const base64Data = sprite.split(',')[1]
|
||||||
|
return Buffer.from(base64Data, 'base64')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateMassCenter(density: number[]): number {
|
||||||
|
let totalMass = 0;
|
||||||
|
let weightedSum = 0;
|
||||||
|
|
||||||
|
density.forEach((mass, position) => {
|
||||||
|
totalMass += mass;
|
||||||
|
weightedSum += position * mass;
|
||||||
|
});
|
||||||
|
|
||||||
|
return totalMass ? Math.round(weightedSum / totalMass) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async normalizeIsometricSprite(
|
||||||
|
buffer: Buffer,
|
||||||
|
frameWidth: number,
|
||||||
|
frameHeight: number,
|
||||||
|
): 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({
|
||||||
|
create: {
|
||||||
|
width: frameWidth,
|
||||||
|
height: frameHeight,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.composite([{
|
||||||
|
input: buffer,
|
||||||
|
left: adjustedOffset,
|
||||||
|
top: 0,
|
||||||
|
}])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
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[]> {
|
// Find spine (densest vertical line in upper body)
|
||||||
return Promise.all(
|
let maxDensity = 0;
|
||||||
spriteActions.map(async (spriteAction) => {
|
let spinePosition = 0;
|
||||||
const { action, sprites } = spriteAction
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (upperBodyDensity[x] > maxDensity) {
|
||||||
if (!Array.isArray(sprites) || sprites.length === 0) {
|
maxDensity = upperBodyDensity[x];
|
||||||
gameMasterLogger.error(`Invalid sprites array for action: ${action}`)
|
spinePosition = x;
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
// Calculate weighted mass center
|
||||||
const image = sharp(buffer)
|
const upperMassCenter = this.calculateMassCenter(upperBodyDensity);
|
||||||
|
const lowerMassCenter = this.calculateMassCenter(columnDensity);
|
||||||
|
|
||||||
// Remove any transparent edges
|
const massCenter = Math.round(
|
||||||
const trimmed = await image
|
upperMassCenter * this.ISOMETRIC.bodyRatios.weightUpper +
|
||||||
.trim()
|
lowerMassCenter * this.ISOMETRIC.bodyRatios.weightLower
|
||||||
.toBuffer()
|
);
|
||||||
|
|
||||||
// Optional: Ensure dimensions are even numbers
|
return {
|
||||||
const metadata = await sharp(trimmed).metadata()
|
massCenter,
|
||||||
const width = Math.ceil(metadata.width! / 2) * 2
|
spinePosition,
|
||||||
const height = Math.ceil(metadata.height! / 2) * 2
|
contentBounds: {
|
||||||
|
left: leftmost,
|
||||||
return sharp(trimmed)
|
right: rightmost,
|
||||||
.resize(width, height, {
|
top: topmost,
|
||||||
fit: 'contain',
|
bottom: bottommost,
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
width: rightmost - leftmost + 1,
|
||||||
})
|
height: bottommost - topmost + 1
|
||||||
.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
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private async saveSpritesToDisk(
|
||||||
|
id: string,
|
||||||
|
processedActions: ProcessedSpriteAction[]
|
||||||
|
): Promise<void> {
|
||||||
|
const publicFolder = getPublicPath('sprites', id)
|
||||||
|
await mkdir(publicFolder, { recursive: true })
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
processedActions.map(async (action) => {
|
||||||
|
const spritesheet = await this.createSpritesheet(
|
||||||
|
action.buffersWithDimensions,
|
||||||
|
action.frameWidth,
|
||||||
|
action.frameHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
const filename = getPublicPath('sprites', id, `${action.action}.png`)
|
||||||
|
await writeFile(filename, spritesheet)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSpritesheet(
|
||||||
|
frames: ProcessedBuffer[],
|
||||||
|
frameWidth: number,
|
||||||
|
frameHeight: number
|
||||||
|
): Promise<Buffer> {
|
||||||
|
// Create background with precise isometric tile width
|
||||||
|
const background = await sharp({
|
||||||
|
create: {
|
||||||
|
width: this.ISOMETRIC_SETTINGS.tileWidth * frames.length,
|
||||||
|
height: frameHeight,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||||
|
}
|
||||||
|
}).png().toBuffer();
|
||||||
|
|
||||||
|
// Composite frames with exact tile-based positioning
|
||||||
|
return sharp(background)
|
||||||
|
.composite(
|
||||||
|
frames.map((frame, index) => ({
|
||||||
|
input: frame.buffer,
|
||||||
|
left: index * this.ISOMETRIC_SETTINGS.tileWidth,
|
||||||
|
top: 0
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateDatabase(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
processedActions: ProcessedSpriteAction[]
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.sprite.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
spriteActions: {
|
||||||
|
deleteMany: { spriteId: id },
|
||||||
|
create: processedActions.map(action => ({
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user