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
276
frontend/src/store/canvasStore.ts
Normal file
276
frontend/src/store/canvasStore.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { CANVAS_CONFIG, COLORS } from '@gaplace/shared';
|
||||
|
||||
interface PixelData {
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
timestamp?: number;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface PixelInfo {
|
||||
color: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
chunkX: number;
|
||||
chunkY: number;
|
||||
pixels: Map<string, PixelInfo>; // key: "x,y", value: pixel info
|
||||
isLoaded: boolean;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
interface Viewport {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
interface CanvasState {
|
||||
// Canvas data
|
||||
canvasId: string;
|
||||
chunks: Map<string, Chunk>; // key: "chunkX,chunkY"
|
||||
|
||||
// Viewport
|
||||
viewport: Viewport;
|
||||
|
||||
// Tools
|
||||
selectedColor: string;
|
||||
selectedTool: 'pixel' | 'fill' | 'eyedropper';
|
||||
brushSize: number;
|
||||
|
||||
// UI state
|
||||
isLoading: boolean;
|
||||
isPanning: boolean;
|
||||
showGrid: boolean;
|
||||
showCursors: boolean;
|
||||
|
||||
// User presence
|
||||
activeUsers: string[];
|
||||
userCursors: Map<string, { x: number; y: number; username: string; color: string }>;
|
||||
|
||||
// Stats
|
||||
totalPixels: number;
|
||||
userPixels: number;
|
||||
|
||||
// Actions
|
||||
setCanvasId: (id: string) => void;
|
||||
setPixel: (x: number, y: number, color: string, userId?: string, username?: string) => void;
|
||||
loadChunk: (chunkX: number, chunkY: number, pixels: PixelData[]) => void;
|
||||
setViewport: (viewport: Partial<Viewport>) => void;
|
||||
setZoom: (zoom: number, centerX?: number, centerY?: number) => void;
|
||||
pan: (deltaX: number, deltaY: number) => void;
|
||||
setSelectedColor: (color: string) => void;
|
||||
setSelectedTool: (tool: 'pixel' | 'fill' | 'eyedropper') => void;
|
||||
setBrushSize: (size: number) => void;
|
||||
setUserCursor: (userId: string, x: number, y: number, username: string, color: string) => void;
|
||||
removeUserCursor: (userId: string) => void;
|
||||
setActiveUsers: (users: string[]) => void;
|
||||
setStats: (totalPixels: number, userPixels?: number) => void;
|
||||
getPixelAt: (x: number, y: number) => string | null;
|
||||
getPixelInfo: (x: number, y: number) => PixelInfo | null;
|
||||
getChunkKey: (chunkX: number, chunkY: number) => string;
|
||||
getPixelKey: (x: number, y: number) => string;
|
||||
getChunkCoordinates: (x: number, y: number) => { chunkX: number; chunkY: number };
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
canvasId: 'default',
|
||||
chunks: new Map(),
|
||||
viewport: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
zoom: 1,
|
||||
},
|
||||
selectedColor: COLORS.PALETTE[0],
|
||||
selectedTool: 'pixel',
|
||||
brushSize: 1,
|
||||
isLoading: false,
|
||||
isPanning: false,
|
||||
showGrid: true,
|
||||
showCursors: true,
|
||||
activeUsers: [],
|
||||
userCursors: new Map(),
|
||||
totalPixels: 0,
|
||||
userPixels: 0,
|
||||
|
||||
// Actions
|
||||
setCanvasId: (id) => set({ canvasId: id }),
|
||||
|
||||
setPixel: (x, y, color, userId, username) => {
|
||||
const state = get();
|
||||
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
|
||||
const chunkKey = state.getChunkKey(chunkX, chunkY);
|
||||
const pixelKey = state.getPixelKey(
|
||||
x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE,
|
||||
y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE
|
||||
);
|
||||
|
||||
const chunks = new Map(state.chunks);
|
||||
let chunk = chunks.get(chunkKey);
|
||||
|
||||
if (!chunk) {
|
||||
chunk = {
|
||||
chunkX,
|
||||
chunkY,
|
||||
pixels: new Map(),
|
||||
isLoaded: true,
|
||||
lastModified: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const pixelInfo: PixelInfo = {
|
||||
color,
|
||||
userId,
|
||||
username,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
chunk.pixels.set(pixelKey, pixelInfo);
|
||||
chunk.lastModified = Date.now();
|
||||
chunks.set(chunkKey, chunk);
|
||||
|
||||
set({ chunks });
|
||||
},
|
||||
|
||||
loadChunk: (chunkX, chunkY, pixels) => {
|
||||
const state = get();
|
||||
const chunkKey = state.getChunkKey(chunkX, chunkY);
|
||||
const chunks = new Map(state.chunks);
|
||||
|
||||
const pixelMap = new Map<string, PixelInfo>();
|
||||
pixels.forEach((pixel) => {
|
||||
const localX = pixel.x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
|
||||
const localY = pixel.y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
|
||||
const pixelKey = state.getPixelKey(localX, localY);
|
||||
pixelMap.set(pixelKey, {
|
||||
color: pixel.color,
|
||||
userId: pixel.userId,
|
||||
username: pixel.username,
|
||||
timestamp: pixel.timestamp
|
||||
});
|
||||
});
|
||||
|
||||
const chunk: Chunk = {
|
||||
chunkX,
|
||||
chunkY,
|
||||
pixels: pixelMap,
|
||||
isLoaded: true,
|
||||
lastModified: Date.now(),
|
||||
};
|
||||
|
||||
chunks.set(chunkKey, chunk);
|
||||
set({ chunks });
|
||||
},
|
||||
|
||||
setViewport: (newViewport) => {
|
||||
const state = get();
|
||||
set({
|
||||
viewport: { ...state.viewport, ...newViewport },
|
||||
});
|
||||
},
|
||||
|
||||
setZoom: (zoom, centerX, centerY) => {
|
||||
const state = get();
|
||||
const clampedZoom = Math.max(CANVAS_CONFIG.MIN_ZOOM, Math.min(CANVAS_CONFIG.MAX_ZOOM, zoom));
|
||||
|
||||
let newViewport = { ...state.viewport, zoom: clampedZoom };
|
||||
|
||||
// If center point is provided, zoom towards that point
|
||||
if (centerX !== undefined && centerY !== undefined) {
|
||||
const zoomFactor = clampedZoom / state.viewport.zoom;
|
||||
newViewport.x = centerX - (centerX - state.viewport.x) * zoomFactor;
|
||||
newViewport.y = centerY - (centerY - state.viewport.y) * zoomFactor;
|
||||
}
|
||||
|
||||
set({ viewport: newViewport });
|
||||
},
|
||||
|
||||
pan: (deltaX, deltaY) => {
|
||||
const state = get();
|
||||
set({
|
||||
viewport: {
|
||||
...state.viewport,
|
||||
x: state.viewport.x + deltaX,
|
||||
y: state.viewport.y + deltaY,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedColor: (color) => set({ selectedColor: color }),
|
||||
setSelectedTool: (tool) => set({ selectedTool: tool }),
|
||||
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(10, size)) }),
|
||||
|
||||
setUserCursor: (userId, x, y, username, color) => {
|
||||
const state = get();
|
||||
const userCursors = new Map(state.userCursors);
|
||||
userCursors.set(userId, { x, y, username, color });
|
||||
set({ userCursors });
|
||||
},
|
||||
|
||||
removeUserCursor: (userId) => {
|
||||
const state = get();
|
||||
const userCursors = new Map(state.userCursors);
|
||||
userCursors.delete(userId);
|
||||
set({ userCursors });
|
||||
},
|
||||
|
||||
setActiveUsers: (users) => set({ activeUsers: users }),
|
||||
setStats: (totalPixels, userPixels) => set({
|
||||
totalPixels,
|
||||
...(userPixels !== undefined && { userPixels })
|
||||
}),
|
||||
|
||||
getPixelAt: (x, y) => {
|
||||
const state = get();
|
||||
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
|
||||
const chunkKey = state.getChunkKey(chunkX, chunkY);
|
||||
const chunk = state.chunks.get(chunkKey);
|
||||
|
||||
if (!chunk) return null;
|
||||
|
||||
const localX = x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
|
||||
const localY = y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
|
||||
const pixelKey = state.getPixelKey(localX, localY);
|
||||
|
||||
return chunk.pixels.get(pixelKey)?.color || null;
|
||||
},
|
||||
|
||||
getPixelInfo: (x, y) => {
|
||||
const state = get();
|
||||
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
|
||||
const chunkKey = state.getChunkKey(chunkX, chunkY);
|
||||
const chunk = state.chunks.get(chunkKey);
|
||||
|
||||
if (!chunk) return null;
|
||||
|
||||
const localX = x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
|
||||
const localY = y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
|
||||
const pixelKey = state.getPixelKey(localX, localY);
|
||||
|
||||
return chunk.pixels.get(pixelKey) || null;
|
||||
},
|
||||
|
||||
getChunkKey: (chunkX, chunkY) => `${chunkX},${chunkY}`,
|
||||
getPixelKey: (x, y) => `${x},${y}`,
|
||||
getChunkCoordinates: (x, y) => ({
|
||||
chunkX: Math.floor(x / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE),
|
||||
chunkY: Math.floor(y / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE),
|
||||
}),
|
||||
}),
|
||||
{ name: 'canvas-store' }
|
||||
)
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue