turash/bugulma/frontend/lib/pixel-art/renderer.ts
Damir Mukimov 6347f42e20
Consolidate repositories: Remove nested frontend .git and merge into main repository
- Remove nested git repository from bugulma/frontend/.git
- Add all frontend files to main repository tracking
- Convert from separate frontend/backend repos to unified monorepo
- Preserve all frontend code and development history as tracked files
- Eliminate nested repository complexity for simpler development workflow

This creates a proper monorepo structure with frontend and backend
coexisting in the same repository for easier development and deployment.
2025-11-25 06:02:57 +01:00

781 lines
22 KiB
TypeScript

/**
* Pixel Art Library - Core Renderer
*
* Main rendering engine for pixel-perfect canvas drawing
* Optimized for performance with batch operations and efficient algorithms
*/
import type {
PixelArtConfig,
Point,
Rectangle,
Circle,
Triangle,
LinearGradient,
RadialGradient,
PixelArtLayer,
ColorPalette,
} from '@/lib/pixel-art/types';
/**
* Color cache for performance optimization
*/
interface ColorCache {
[key: string]: string;
}
/**
* RGBA color representation for efficient pixel operations
*/
interface RGBAColor {
r: number;
g: number;
b: number;
a: number;
}
export class PixelArtRenderer {
private ctx: CanvasRenderingContext2D;
private config: PixelArtConfig;
private palette?: ColorPalette;
private colorCache: ColorCache = {};
private rgbaCache: Map<string, RGBAColor> = new Map();
private imageDataCache: ImageData | null = null;
constructor(ctx: CanvasRenderingContext2D, config: PixelArtConfig, palette?: ColorPalette) {
this.ctx = ctx;
this.config = config;
this.palette = palette;
// Set up canvas for pixel-perfect rendering
this.setupCanvas();
}
private setupCanvas(): void {
const { ctx, config } = this;
const pixelSize = config.pixelSize || 1;
const scale = config.scale || 1;
// Disable image smoothing for crisp pixels - CRITICAL for pixel art
ctx.imageSmoothingEnabled = false;
ctx.imageSmoothingQuality = 'low';
// Set canvas size to exact pixel dimensions
const displayWidth = Math.floor(config.width * pixelSize * scale);
const displayHeight = Math.floor(config.height * pixelSize * scale);
// Set actual canvas resolution (important for pixel-perfect rendering)
ctx.canvas.width = displayWidth;
ctx.canvas.height = displayHeight;
// Set CSS size to match display size for pixel-perfect rendering
ctx.canvas.style.width = `${displayWidth}px`;
ctx.canvas.style.height = `${displayHeight}px`;
// Enhanced browser compatibility for pixel-perfect rendering
const canvas = ctx.canvas;
canvas.style.imageRendering = 'pixelated';
canvas.style.imageRendering = '-moz-crisp-edges';
canvas.style.imageRendering = 'crisp-edges';
canvas.style.imageRendering = '-webkit-optimize-contrast';
// Fallback for older browsers
if (!('imageRendering' in canvas.style)) {
canvas.style.msInterpolationMode = 'nearest-neighbor';
}
// Scale context - use integer scaling for pixel-perfect rendering
const scaleFactor = pixelSize * scale;
ctx.scale(scaleFactor, scaleFactor);
// Invalidate cache when canvas is resized
this.imageDataCache = null;
}
/**
* Clear the canvas
*/
clear(): void {
this.ctx.clearRect(0, 0, this.config.width, this.config.height);
}
/**
* Parse color string to RGBA for efficient operations
*/
private parseColor(color: string, alpha: number = 1): RGBAColor {
const cacheKey = `${color}:${alpha}`;
if (this.rgbaCache.has(cacheKey)) {
return this.rgbaCache.get(cacheKey)!;
}
let rgba: RGBAColor;
if (color.startsWith('rgba')) {
const matches = color.match(/\d+/g);
if (matches && matches.length >= 3) {
rgba = {
r: parseInt(matches[0], 10),
g: parseInt(matches[1], 10),
b: parseInt(matches[2], 10),
a: matches[3] ? parseFloat(matches[3]) : alpha,
};
} else {
rgba = { r: 0, g: 0, b: 0, a: alpha };
}
} else if (color.startsWith('rgb')) {
const matches = color.match(/\d+/g);
if (matches && matches.length >= 3) {
rgba = {
r: parseInt(matches[0], 10),
g: parseInt(matches[1], 10),
b: parseInt(matches[2], 10),
a: alpha,
};
} else {
rgba = { r: 0, g: 0, b: 0, a: alpha };
}
} else if (color.startsWith('#')) {
const hex = color.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
rgba = { r, g, b, a: alpha };
} else {
// Fallback for named colors - try to parse via canvas
const tempCtx = document.createElement('canvas').getContext('2d');
if (tempCtx) {
tempCtx.fillStyle = color;
const computed = tempCtx.fillStyle;
if (computed.startsWith('#')) {
return this.parseColor(computed, alpha);
}
}
rgba = { r: 0, g: 0, b: 0, a: alpha };
}
this.rgbaCache.set(cacheKey, rgba);
return rgba;
}
/**
* Draw a single pixel (pixel-perfect)
* Optimized with direct pixel manipulation for better performance
*/
pixel(x: number, y: number, color: string, alpha: number = 1): void {
const { ctx } = this;
// Round to integer for pixel-perfect rendering
const px = Math.floor(x);
const py = Math.floor(y);
// Bounds check
if (px < 0 || px >= this.config.width || py < 0 || py >= this.config.height) {
return;
}
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.fillRect(px, py, 1, 1);
ctx.globalAlpha = 1;
}
/**
* Draw multiple pixels efficiently using ImageData
* Use this for batch pixel operations
*/
pixels(points: Array<{ x: number; y: number; color: string; alpha?: number }>): void {
if (points.length === 0) return;
const { ctx } = this;
const width = this.config.width;
const height = this.config.height;
// Get or create ImageData
if (
!this.imageDataCache ||
this.imageDataCache.width !== width ||
this.imageDataCache.height !== height
) {
this.imageDataCache = ctx.createImageData(width, height);
}
const data = this.imageDataCache.data;
// Draw all pixels
for (const point of points) {
const px = Math.floor(point.x);
const py = Math.floor(point.y);
if (px < 0 || px >= width || py < 0 || py >= height) continue;
const rgba = this.parseColor(point.color, point.alpha ?? 1);
const index = (py * width + px) * 4;
// Blend with existing pixel if alpha < 1
if (rgba.a < 1 && data[index + 3] > 0) {
const existingAlpha = data[index + 3] / 255;
const newAlpha = rgba.a + existingAlpha * (1 - rgba.a);
const blend = (newValue: number, oldValue: number) =>
Math.round((rgba.a * newValue + existingAlpha * (1 - rgba.a) * oldValue) / newAlpha);
data[index] = blend(rgba.r, data[index]);
data[index + 1] = blend(rgba.g, data[index + 1]);
data[index + 2] = blend(rgba.b, data[index + 2]);
data[index + 3] = Math.round(newAlpha * 255);
} else {
data[index] = rgba.r;
data[index + 1] = rgba.g;
data[index + 2] = rgba.b;
data[index + 3] = Math.round(rgba.a * 255);
}
}
// Put ImageData back to canvas
ctx.putImageData(this.imageDataCache, 0, 0);
}
/**
* Draw a rectangle (pixel-perfect)
*/
rect(rect: Rectangle, color: string, alpha: number = 1): void {
const { ctx } = this;
// Round to integers for pixel-perfect rendering
const x = Math.floor(rect.x);
const y = Math.floor(rect.y);
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.fillRect(x, y, w, h);
ctx.globalAlpha = 1;
}
/**
* Draw a rectangle outline
*/
rectOutline(rect: Rectangle, color: string, lineWidth: number = 1, alpha: number = 1): void {
const { ctx } = this;
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.globalAlpha = 1;
}
/**
* Draw a circle using midpoint circle algorithm (more efficient)
* Optimized for pixel art with perfect pixel placement
*/
circle(circle: Circle, color: string, alpha: number = 1): void {
const cx = Math.floor(circle.x);
const cy = Math.floor(circle.y);
const radius = Math.floor(circle.radius);
// Use midpoint circle algorithm for better performance and accuracy
this.midpointCircle(cx, cy, radius, color, alpha);
}
/**
* Midpoint circle algorithm - more efficient than pixel-by-pixel
*/
private midpointCircle(
cx: number,
cy: number,
radius: number,
color: string,
alpha: number
): void {
const { ctx } = this;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
let x = radius;
let y = 0;
let err = 0;
// Draw 8 octants simultaneously
const drawOctants = (x: number, y: number) => {
if (cx + x >= 0 && cx + x < this.config.width && cy + y >= 0 && cy + y < this.config.height) {
ctx.fillRect(cx + x, cy + y, 1, 1);
}
if (cx - x >= 0 && cx - x < this.config.width && cy + y >= 0 && cy + y < this.config.height) {
ctx.fillRect(cx - x, cy + y, 1, 1);
}
if (cx + x >= 0 && cx + x < this.config.width && cy - y >= 0 && cy - y < this.config.height) {
ctx.fillRect(cx + x, cy - y, 1, 1);
}
if (cx - x >= 0 && cx - x < this.config.width && cy - y >= 0 && cy - y < this.config.height) {
ctx.fillRect(cx - x, cy - y, 1, 1);
}
if (cx + y >= 0 && cx + y < this.config.width && cy + x >= 0 && cy + x < this.config.height) {
ctx.fillRect(cx + y, cy + x, 1, 1);
}
if (cx - y >= 0 && cx - y < this.config.width && cy + x >= 0 && cy + x < this.config.height) {
ctx.fillRect(cx - y, cy + x, 1, 1);
}
if (cx + y >= 0 && cx + y < this.config.width && cy - x >= 0 && cy - x < this.config.height) {
ctx.fillRect(cx + y, cy - x, 1, 1);
}
if (cx - y >= 0 && cx - y < this.config.width && cy - x >= 0 && cy - x < this.config.height) {
ctx.fillRect(cx - y, cy - x, 1, 1);
}
};
// Fill circle by drawing horizontal lines
const fillCircle = (x: number, y: number) => {
if (y === 0) {
// Draw horizontal line through center
const startX = Math.max(0, cx - x);
const endX = Math.min(this.config.width, cx + x + 1);
const width = endX - startX;
if (width > 0 && cy >= 0 && cy < this.config.height) {
ctx.fillRect(startX, cy, width, 1);
}
} else {
// Draw two horizontal lines (top and bottom)
const startX1 = Math.max(0, cx - x);
const endX1 = Math.min(this.config.width, cx + x + 1);
const width1 = endX1 - startX1;
if (width1 > 0) {
if (cy + y >= 0 && cy + y < this.config.height) {
ctx.fillRect(startX1, cy + y, width1, 1);
}
if (cy - y >= 0 && cy - y < this.config.height) {
ctx.fillRect(startX1, cy - y, width1, 1);
}
}
const startX2 = Math.max(0, cx - y);
const endX2 = Math.min(this.config.width, cx + y + 1);
const width2 = endX2 - startX2;
if (width2 > 0) {
if (cy + x >= 0 && cy + x < this.config.height) {
ctx.fillRect(startX2, cy + x, width2, 1);
}
if (cy - x >= 0 && cy - x < this.config.height) {
ctx.fillRect(startX2, cy - x, width2, 1);
}
}
}
};
while (x >= y) {
fillCircle(x, y);
drawOctants(x, y);
if (err <= 0) {
y += 1;
err += 2 * y + 1;
}
if (err > 0) {
x -= 1;
err -= 2 * x + 1;
}
}
ctx.globalAlpha = 1;
}
/**
* Draw a circle outline
*/
circleOutline(circle: Circle, color: string, lineWidth: number = 1, alpha: number = 1): void {
const { ctx } = this;
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
/**
* Draw a triangle (pixelated version for pixel art)
*/
triangle(triangle: Triangle, color: string, alpha: number = 1): void {
const { ctx } = this;
// Round coordinates for pixel-perfect rendering
const p1 = { x: Math.floor(triangle.p1.x), y: Math.floor(triangle.p1.y) };
const p2 = { x: Math.floor(triangle.p2.x), y: Math.floor(triangle.p2.y) };
const p3 = { x: Math.floor(triangle.p3.x), y: Math.floor(triangle.p3.y) };
// Find bounding box
const minX = Math.max(0, Math.min(p1.x, p2.x, p3.x));
const maxX = Math.min(this.config.width, Math.max(p1.x, p2.x, p3.x));
const minY = Math.max(0, Math.min(p1.y, p2.y, p3.y));
const maxY = Math.min(this.config.height, Math.max(p1.y, p2.y, p3.y));
// Helper function to check if point is inside triangle
const pointInTriangle = (px: number, py: number): boolean => {
const v0x = p3.x - p1.x;
const v0y = p3.y - p1.y;
const v1x = p2.x - p1.x;
const v1y = p2.y - p1.y;
const v2x = px - p1.x;
const v2y = py - p1.y;
const dot00 = v0x * v0x + v0y * v0y;
const dot01 = v0x * v1x + v0y * v1y;
const dot02 = v0x * v2x + v0y * v2y;
const dot11 = v1x * v1x + v1y * v1y;
const dot12 = v1x * v2x + v1y * v2y;
const invDenom = 1 / (dot00 * dot11 - dot01 * dot01);
const u = (dot11 * dot02 - dot01 * dot12) * invDenom;
const v = (dot00 * dot12 - dot01 * dot02) * invDenom;
return u >= 0 && v >= 0 && u + v <= 1;
};
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
// Fill triangle pixel by pixel
for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
if (pointInTriangle(x, y)) {
ctx.fillRect(x, y, 1, 1);
}
}
}
ctx.globalAlpha = 1;
}
/**
* Draw a triangle outline
*/
triangleOutline(
triangle: Triangle,
color: string,
lineWidth: number = 1,
alpha: number = 1
): void {
const { ctx } = this;
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(triangle.p1.x, triangle.p1.y);
ctx.lineTo(triangle.p2.x, triangle.p2.y);
ctx.lineTo(triangle.p3.x, triangle.p3.y);
ctx.closePath();
ctx.stroke();
ctx.globalAlpha = 1;
}
/**
* Draw a line using Bresenham's algorithm for pixel-perfect rendering
* Much more accurate for pixel art than canvas stroke
*/
line(from: Point, to: Point, color: string, lineWidth: number = 1, alpha: number = 1): void {
// For pixel art, use Bresenham's algorithm for perfect pixel lines
if (lineWidth === 1) {
this.bresenhamLine(from, to, color, alpha);
} else {
// For thicker lines, use canvas stroke (less optimal but handles width)
const { ctx } = this;
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(Math.floor(from.x) + 0.5, Math.floor(from.y) + 0.5);
ctx.lineTo(Math.floor(to.x) + 0.5, Math.floor(to.y) + 0.5);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
/**
* Bresenham's line algorithm for pixel-perfect lines
*/
private bresenhamLine(from: Point, to: Point, color: string, alpha: number = 1): void {
let x0 = Math.floor(from.x);
let y0 = Math.floor(from.y);
const x1 = Math.floor(to.x);
const y1 = Math.floor(to.y);
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
while (true) {
this.pixel(x0, y0, color, alpha);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
}
/**
* Draw a linear gradient
*/
linearGradient(
gradient: LinearGradient,
shape: Rectangle | Circle | Triangle,
alpha: number = 1
): void {
const { ctx } = this;
const grad = ctx.createLinearGradient(gradient.x1, gradient.y1, gradient.x2, gradient.y2);
gradient.stops.forEach((stop) => {
const color =
stop.alpha !== undefined ? this.colorWithAlpha(stop.color, stop.alpha) : stop.color;
grad.addColorStop(stop.offset, color);
});
ctx.globalAlpha = alpha;
ctx.fillStyle = grad;
if ('width' in shape) {
// Rectangle
ctx.fillRect(shape.x, shape.y, shape.width, shape.height);
} else if ('radius' in shape) {
// Circle
ctx.beginPath();
ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
ctx.fill();
} else {
// Triangle
ctx.beginPath();
ctx.moveTo(shape.p1.x, shape.p1.y);
ctx.lineTo(shape.p2.x, shape.p2.y);
ctx.lineTo(shape.p3.x, shape.p3.y);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
/**
* Draw a radial gradient
*/
radialGradient(gradient: RadialGradient, shape: Circle, alpha: number = 1): void {
const { ctx } = this;
const grad = ctx.createRadialGradient(
gradient.cx,
gradient.cy,
0,
gradient.cx,
gradient.cy,
gradient.r
);
gradient.stops.forEach((stop) => {
const color =
stop.alpha !== undefined ? this.colorWithAlpha(stop.color, stop.alpha) : stop.color;
grad.addColorStop(stop.offset, color);
});
ctx.globalAlpha = alpha;
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
/**
* Helper to add alpha to color string
*/
private colorWithAlpha(color: string, alpha: number): string {
// If color is already rgba/rgb, convert it
if (color.startsWith('rgba')) {
return color.replace(
/rgba?\([^)]+\)/,
`rgba(${color.match(/\d+/g)?.slice(0, 3).join(',')},${alpha})`
);
}
if (color.startsWith('#')) {
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
return color;
}
/**
* Draw multiple layers
*/
drawLayers(layers: PixelArtLayer[]): void {
layers.forEach((layer) => {
if (layer.visible !== false) {
this.ctx.save();
if (layer.opacity !== undefined) {
this.ctx.globalAlpha = layer.opacity;
}
if (layer.blendMode) {
this.ctx.globalCompositeOperation = layer.blendMode;
}
layer.draw(this.ctx);
this.ctx.restore();
}
});
}
/**
* Get color from palette with caching for performance
*/
color(colorName: string): string {
// Check cache first
if (this.colorCache[colorName]) {
return this.colorCache[colorName];
}
let result: string;
if (this.palette) {
result = this.palette.colors[colorName] || colorName;
} else {
result = colorName;
}
// Cache the result
this.colorCache[colorName] = result;
return result;
}
/**
* Clear color cache (useful when palette changes)
*/
clearColorCache(): void {
this.colorCache = {};
this.rgbaCache.clear();
}
/**
* Update palette and clear cache
*/
setPalette(palette: ColorPalette): void {
this.palette = palette;
this.clearColorCache();
}
/**
* Draw a sprite from 2D array data
*/
sprite(data: number[][], palette: string[]): void {
data.forEach((row, y) => {
row.forEach((pixel, x) => {
if (pixel > 0 && pixel <= palette.length) {
this.pixel(x, y, palette[pixel - 1]);
}
});
});
}
/**
* Draw a pattern
* Optimized to batch pixel operations when possible
*/
pattern(pattern: (x: number, y: number) => string | null, bounds: Rectangle): void {
const pixels: Array<{ x: number; y: number; color: string }> = [];
const minX = Math.max(0, Math.floor(bounds.x));
const maxX = Math.min(this.config.width, Math.floor(bounds.x + bounds.width));
const minY = Math.max(0, Math.floor(bounds.y));
const maxY = Math.min(this.config.height, Math.floor(bounds.y + bounds.height));
for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
const color = pattern(x, y);
if (color) {
pixels.push({ x, y, color });
}
}
}
// Batch draw pixels for better performance
if (pixels.length > 100) {
this.pixels(pixels);
} else {
// For small patterns, use individual pixel calls
for (const pixel of pixels) {
this.pixel(pixel.x, pixel.y, pixel.color);
}
}
}
/**
* Draw a filled polygon using scanline algorithm
* More efficient than triangle-by-triangle for complex shapes
*/
polygon(points: Point[], color: string, alpha: number = 1): void {
if (points.length < 3) return;
const { ctx } = this;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
// Find bounding box
let minX = points[0].x;
let maxX = points[0].x;
let minY = points[0].y;
let maxY = points[0].y;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
minX = Math.max(0, Math.floor(minX));
maxX = Math.min(this.config.width, Math.floor(maxX) + 1);
minY = Math.max(0, Math.floor(minY));
maxY = Math.min(this.config.height, Math.floor(maxY) + 1);
// Point-in-polygon test using ray casting
const pointInPolygon = (x: number, y: number): boolean => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x;
const yi = points[i].y;
const xj = points[j].x;
const yj = points[j].y;
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
};
// Fill polygon pixel by pixel
for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
if (pointInPolygon(x + 0.5, y + 0.5)) {
ctx.fillRect(x, y, 1, 1);
}
}
}
ctx.globalAlpha = 1;
}
/**
* Get current canvas as ImageData for further processing
*/
getImageData(): ImageData {
return this.ctx.getImageData(0, 0, this.config.width, this.config.height);
}
/**
* Draw ImageData to canvas (useful for effects and filters)
*/
putImageData(imageData: ImageData, dx: number = 0, dy: number = 0): void {
this.ctx.putImageData(imageData, dx, dy);
}
}