Modernize collaborative pixel art platform to production-ready architecture
Major refactor from simple HTML/JS app to modern full-stack TypeScript application: ## Architecture Changes - Migrated to monorepo structure with workspaces (backend, frontend, shared) - Backend: Node.js + Express + TypeScript + Socket.IO - Frontend: Next.js 15.5 + React 19 + TypeScript + Tailwind CSS - Shared: Common types and utilities across packages ## Key Features Implemented - Real-time WebSocket collaboration via Socket.IO - Virtual canvas with chunked loading for performance - Modern UI with dark mode and responsive design - Mock database system for easy development (Redis/PostgreSQL compatible) - Comprehensive error handling and rate limiting - User presence and cursor tracking - Infinite canvas support with zoom/pan controls ## Performance Optimizations - Canvas virtualization - only renders visible viewport - Chunked pixel data loading (64x64 pixel chunks) - Optimized WebSocket protocol - Memory-efficient state management with Zustand ## Development Experience - Full TypeScript support across all packages - Hot reload for both frontend and backend - Docker support for production deployment - Comprehensive linting and formatting - Automated development server startup ## Fixed Issues - Corrected start script paths - Updated environment configuration - Fixed ESLint configuration issues - Ensured all dependencies are properly installed - Verified build process works correctly 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ee5b0bee92
commit
1da96f34a6
69 changed files with 17771 additions and 1589 deletions
153
backend/src/services/CanvasService.ts
Normal file
153
backend/src/services/CanvasService.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { redisClient } from '../config/database-factory';
|
||||
import {
|
||||
Pixel,
|
||||
PixelChunk,
|
||||
getChunkCoordinates,
|
||||
getChunkKey,
|
||||
getPixelKey,
|
||||
CANVAS_CONFIG
|
||||
} from '@gaplace/shared';
|
||||
import { config } from '../config/env';
|
||||
|
||||
export class CanvasService {
|
||||
private readonly keyPrefix = config.redis.keyPrefix;
|
||||
|
||||
async placePixel(canvasId: string, x: number, y: number, color: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const { chunkX, chunkY } = getChunkCoordinates(x, y);
|
||||
const chunkKey = `${this.keyPrefix}canvas:${canvasId}:chunk:${getChunkKey(chunkX, chunkY)}`;
|
||||
const pixelKey = getPixelKey(x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE, y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
|
||||
// Simple approach without pipeline for better compatibility
|
||||
await redisClient.hSet(chunkKey, pixelKey, color);
|
||||
|
||||
// Update chunk metadata
|
||||
await redisClient.hSet(`${chunkKey}:meta`, {
|
||||
lastModified: Date.now().toString(),
|
||||
lastUser: userId,
|
||||
});
|
||||
|
||||
// Track user pixel count
|
||||
await redisClient.incr(`${this.keyPrefix}user:${userId}:pixels`);
|
||||
|
||||
// Update canvas stats
|
||||
await redisClient.incr(`${this.keyPrefix}canvas:${canvasId}:totalPixels`);
|
||||
await redisClient.set(`${this.keyPrefix}canvas:${canvasId}:lastActivity`, Date.now().toString());
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error placing pixel:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getChunk(canvasId: string, chunkX: number, chunkY: number): Promise<PixelChunk | null> {
|
||||
try {
|
||||
const chunkKey = `${this.keyPrefix}canvas:${canvasId}:chunk:${getChunkKey(chunkX, chunkY)}`;
|
||||
const [pixelData, metadata] = await Promise.all([
|
||||
redisClient.hGetAll(chunkKey),
|
||||
redisClient.hGetAll(`${chunkKey}:meta`)
|
||||
]);
|
||||
|
||||
if (Object.keys(pixelData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pixels = new Map<string, string>();
|
||||
for (const [key, color] of Object.entries(pixelData)) {
|
||||
pixels.set(key, String(color));
|
||||
}
|
||||
|
||||
return {
|
||||
chunkX,
|
||||
chunkY,
|
||||
pixels,
|
||||
lastModified: parseInt(metadata.lastModified || '0'),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting chunk:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getVisibleChunks(canvasId: string, startX: number, startY: number, endX: number, endY: number): Promise<PixelChunk[]> {
|
||||
const chunks: PixelChunk[] = [];
|
||||
|
||||
const startChunkX = Math.floor(startX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const startChunkY = Math.floor(startY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const endChunkX = Math.floor(endX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const endChunkY = Math.floor(endY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
|
||||
const promises: Promise<PixelChunk | null>[] = [];
|
||||
|
||||
for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX++) {
|
||||
for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY++) {
|
||||
promises.push(this.getChunk(canvasId, chunkX, chunkY));
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
for (const chunk of results) {
|
||||
if (chunk) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async getCanvasStats(canvasId: string): Promise<{
|
||||
totalPixels: number;
|
||||
lastActivity: number;
|
||||
activeUsers: number;
|
||||
}> {
|
||||
try {
|
||||
const [totalPixels, lastActivity, activeUsers] = await Promise.all([
|
||||
redisClient.get(`${this.keyPrefix}canvas:${canvasId}:totalPixels`),
|
||||
redisClient.get(`${this.keyPrefix}canvas:${canvasId}:lastActivity`),
|
||||
redisClient.sCard(`${this.keyPrefix}canvas:${canvasId}:activeUsers`)
|
||||
]);
|
||||
|
||||
return {
|
||||
totalPixels: parseInt(totalPixels || '0'),
|
||||
lastActivity: parseInt(lastActivity || '0'),
|
||||
activeUsers: activeUsers || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting canvas stats:', error);
|
||||
return {
|
||||
totalPixels: 0,
|
||||
lastActivity: 0,
|
||||
activeUsers: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async addActiveUser(canvasId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
await redisClient.sAdd(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, userId);
|
||||
// Set expiration to auto-remove inactive users
|
||||
await redisClient.expire(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, 300); // 5 minutes
|
||||
} catch (error) {
|
||||
console.error('Error adding active user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async removeActiveUser(canvasId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
await redisClient.sRem(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, userId);
|
||||
} catch (error) {
|
||||
console.error('Error removing active user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveUsers(canvasId: string): Promise<string[]> {
|
||||
try {
|
||||
return await redisClient.sMembers(`${this.keyPrefix}canvas:${canvasId}:activeUsers`);
|
||||
} catch (error) {
|
||||
console.error('Error getting active users:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue