mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
- 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.
781 lines
22 KiB
TypeScript
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);
|
|
}
|
|
}
|