UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

611 lines (532 loc) 17.8 kB
import {RGBA, OptimizedBuffer} from '@opentui/core'; import {TILE_SIZE, TileMap} from '../TileMap.ts'; /** * Simple image format for sprites */ export interface GameImage { width: number; height: number; pixels: Uint8Array; // RGBA packed, length = width * height * 4 } /** * Canvas state that can be saved/restored */ interface CanvasState { fillColor: [number, number, number, number]; // RGBA 0-255 strokeColor: [number, number, number, number]; translateX: number; translateY: number; } /** * 2D pixel game engine with canvas-style API * Renders to terminal using half-block characters for proper aspect ratio */ export class PixelCanvas { public width: number; public height: number; private pixels: Uint8Array; private state: CanvasState; private stateStack: CanvasState[] = []; constructor(width: number, height: number) { this.width = width; // Ensure even height for half-block rendering this.height = height % 2 === 0 ? height : height + 1; this.pixels = new Uint8Array(this.width * this.height * 4); this.state = { fillColor: [255, 255, 255, 255], strokeColor: [255, 255, 255, 255], translateX: 0, translateY: 0, }; } resize(width: number, height: number): void { this.width = width; this.height = height % 2 === 0 ? height : height + 1; this.pixels = new Uint8Array(this.width * this.height * 4); } cameraOffsetX: number = 0; cameraOffsetY: number = 0; setCameraOffset(cameraPixelX: number, cameraPixelY: number) { this.cameraOffsetX = cameraPixelX; this.cameraOffsetY = cameraPixelY; } // ============ State Management ============ save(): void { this.stateStack.push({...this.state}); } restore(): void { const prev = this.stateStack.pop(); if (prev) { this.state = prev; } } // ============ Transform ============ translate(x: number, y: number): void { this.state.translateX += x; this.state.translateY += y; } resetTransform(): void { this.state.translateX = 0; this.state.translateY = 0; } // ============ Color Setters ============ setFillColor(r: number, g: number, b: number, a: number = 255): void { this.state.fillColor = [r, g, b, a]; } setStrokeColor(r: number, g: number, b: number, a: number = 255): void { this.state.strokeColor = [r, g, b, a]; } // ============ Pixel Operations ============ private getIndex(x: number, y: number): number { return (y * this.width + x) * 4; } private inBounds(x: number, y: number): boolean { return x >= 0 && x < this.width && y >= 0 && y < this.height; } normalizeRGBA(r: number, g: number, b: number, a: number): [number, number, number, number] { if (r <= 1 && g <= 1 && b <= 1 && a <= 1) { return [r * 255, g * 255, b * 255, a * 255]; } return [ Math.max(0, Math.min(255, r)), Math.max(0, Math.min(255, g)), Math.max(0, Math.min(255, b)), Math.max(0, Math.min(255, a)), ]; } /** * Set a pixel with alpha blending */ private setPixelRaw(x: number, y: number, r: number, g: number, b: number, a: number): void { if (!this.inBounds(x, y)) return; const idx = this.getIndex(x, y); [r, g, b, a] = this.normalizeRGBA(r, g, b, a); if (a === 255) { // Opaque - just set this.pixels[idx] = r; this.pixels[idx + 1] = g; this.pixels[idx + 2] = b; this.pixels[idx + 3] = 255; } else if (a > 0) { // Alpha blend const srcA = a / 255; const dstA = this.pixels[idx + 3] / 255; const outA = srcA + dstA * (1 - srcA); if (outA > 0) { this.pixels[idx] = Math.round((r * srcA + this.pixels[idx] * dstA * (1 - srcA)) / outA); this.pixels[idx + 1] = Math.round( (g * srcA + this.pixels[idx + 1] * dstA * (1 - srcA)) / outA, ); this.pixels[idx + 2] = Math.round( (b * srcA + this.pixels[idx + 2] * dstA * (1 - srcA)) / outA, ); this.pixels[idx + 3] = Math.round(outA * 255); } } } /** * Set pixel at position using current fill color (applies translation) */ setPixel(x: number, y: number, color?: RGBA): void { const tx = Math.floor(x + this.state.translateX); const ty = Math.floor(y + this.state.translateY); if (color) { this.setPixelRaw(tx, ty, color.r, color.g, color.b, color.a); } else { const [r, g, b, a] = this.state.fillColor; this.setPixelRaw(tx, ty, r, g, b, a); } } /** * Get pixel color at position */ getPixel(x: number, y: number): [number, number, number, number] { if (!this.inBounds(x, y)) return [0, 0, 0, 0]; const idx = this.getIndex(x, y); return [this.pixels[idx], this.pixels[idx + 1], this.pixels[idx + 2], this.pixels[idx + 3]]; } // ============ Drawing Primitives ============ /** * Clear the canvas with a color (default black) */ clear(r: number = 0, g: number = 0, b: number = 0, a: number = 255): void { for (let i = 0; i < this.pixels.length; i += 4) { this.pixels[i] = r; this.pixels[i + 1] = g; this.pixels[i + 2] = b; this.pixels[i + 3] = a; } } /** * Draw a filled rectangle */ fillRect(x: number, y: number, w: number, h: number): void { const tx = Math.floor(x + this.state.translateX); const ty = Math.floor(y + this.state.translateY); const [r, g, b, a] = this.state.fillColor; for (let py = ty; py < ty + h; py++) { for (let px = tx; px < tx + w; px++) { this.setPixelRaw(px, py, r, g, b, a); } } } /** * Draw a rectangle outline */ strokeRect(x: number, y: number, w: number, h: number): void { const tx = Math.floor(x + this.state.translateX); const ty = Math.floor(y + this.state.translateY); const [r, g, b, a] = this.state.strokeColor; // Top and bottom for (let px = tx; px < tx + w; px++) { this.setPixelRaw(px, ty, r, g, b, a); this.setPixelRaw(px, ty + h - 1, r, g, b, a); } // Left and right for (let py = ty; py < ty + h; py++) { this.setPixelRaw(tx, py, r, g, b, a); this.setPixelRaw(tx + w - 1, py, r, g, b, a); } } /** * Draw a line using Bresenham's algorithm */ drawLine(x1: number, y1: number, x2: number, y2: number): void { const tx1 = Math.floor(x1 + this.state.translateX); const ty1 = Math.floor(y1 + this.state.translateY); const tx2 = Math.floor(x2 + this.state.translateX); const ty2 = Math.floor(y2 + this.state.translateY); const [r, g, b, a] = this.state.strokeColor; let x = tx1; let y = ty1; const dx = Math.abs(tx2 - tx1); const dy = Math.abs(ty2 - ty1); const sx = tx1 < tx2 ? 1 : -1; const sy = ty1 < ty2 ? 1 : -1; let err = dx - dy; while (true) { this.setPixelRaw(x, y, r, g, b, a); if (x === tx2 && y === ty2) break; const e2 = 2 * err; if (e2 > -dy) { err -= dy; x += sx; } if (e2 < dx) { err += dx; y += sy; } } } /** * Draw a filled ellipse using midpoint algorithm */ fillEllipse(cx: number, cy: number, rx: number, ry: number): void { const tcx = Math.floor(cx + this.state.translateX); const tcy = Math.floor(cy + this.state.translateY); const [r, g, b, a] = this.state.fillColor; // Simple filled ellipse - check each pixel in bounding box for (let py = tcy - ry; py <= tcy + ry; py++) { for (let px = tcx - rx; px <= tcx + rx; px++) { const dx = px - tcx; const dy = py - tcy; if ((dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1) { this.setPixelRaw(px, py, r, g, b, a); } } } } /** * Draw an ellipse outline */ strokeEllipse(cx: number, cy: number, rx: number, ry: number): void { const tcx = Math.floor(cx + this.state.translateX); const tcy = Math.floor(cy + this.state.translateY); const [r, g, b, a] = this.state.strokeColor; // Midpoint ellipse algorithm let x = 0; let y = ry; const rx2 = rx * rx; const ry2 = ry * ry; const twoRx2 = 2 * rx2; const twoRy2 = 2 * ry2; let px = 0; let py = twoRx2 * y; // Plot initial points const plotPoints = (x: number, y: number) => { this.setPixelRaw(tcx + x, tcy + y, r, g, b, a); this.setPixelRaw(tcx - x, tcy + y, r, g, b, a); this.setPixelRaw(tcx + x, tcy - y, r, g, b, a); this.setPixelRaw(tcx - x, tcy - y, r, g, b, a); }; plotPoints(x, y); // Region 1 let p = Math.round(ry2 - rx2 * ry + 0.25 * rx2); while (px < py) { x++; px += twoRy2; if (p < 0) { p += ry2 + px; } else { y--; py -= twoRx2; p += ry2 + px - py; } plotPoints(x, y); } // Region 2 p = Math.round(ry2 * (x + 0.5) * (x + 0.5) + rx2 * (y - 1) * (y - 1) - rx2 * ry2); while (y > 0) { y--; py -= twoRx2; if (p > 0) { p += rx2 - py; } else { x++; px += twoRy2; p += rx2 - py + px; } plotPoints(x, y); } } /** * Draw a filled circle */ fillCircle(cx: number, cy: number, r: number): void { this.fillEllipse(cx, cy, r, r); } /** * Draw a circle outline */ strokeCircle(cx: number, cy: number, r: number): void { this.strokeEllipse(cx, cy, r, r); } // ============ Image Operations ============ /** * Draw an image at position with alpha blending */ drawImage(image: GameImage, x: number, y: number): void { const tx = Math.floor(x + this.state.translateX); const ty = Math.floor(y + this.state.translateY); for (let py = 0; py < image.height; py++) { for (let px = 0; px < image.width; px++) { const srcIdx = (py * image.width + px) * 4; const r = image.pixels[srcIdx]; const g = image.pixels[srcIdx + 1]; const b = image.pixels[srcIdx + 2]; const a = image.pixels[srcIdx + 3]; this.setPixelRaw(tx + px, ty + py, r, g, b, a); } } } /** * Draw a portion of an image (sprite sheet support) */ drawImageRegion( image: GameImage, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, ): void { const tx = Math.floor(dx + this.state.translateX); const ty = Math.floor(dy + this.state.translateY); for (let py = 0; py < sh; py++) { for (let px = 0; px < sw; px++) { const srcX = sx + px; const srcY = sy + py; if (srcX >= image.width || srcY >= image.height) continue; const srcIdx = (srcY * image.width + srcX) * 4; const r = image.pixels[srcIdx]; const g = image.pixels[srcIdx + 1]; const b = image.pixels[srcIdx + 2]; const a = image.pixels[srcIdx + 3]; this.setPixelRaw(tx + px, ty + py, r, g, b, a); } } } /** * Draw an image flipped horizontally */ drawImageFlipX(image: GameImage, x: number, y: number): void { const tx = Math.floor(x + this.state.translateX); const ty = Math.floor(y + this.state.translateY); for (let py = 0; py < image.height; py++) { for (let px = 0; px < image.width; px++) { const srcIdx = (py * image.width + (image.width - 1 - px)) * 4; const r = image.pixels[srcIdx]; const g = image.pixels[srcIdx + 1]; const b = image.pixels[srcIdx + 2]; const a = image.pixels[srcIdx + 3]; this.setPixelRaw(tx + px, ty + py, r, g, b, a); } } } // ============ Terminal Rendering ============ /** * Render the canvas to an OptimizedBuffer using half-block characters * This gives proper 1:1 pixel aspect ratio in terminal */ render(buffer: OptimizedBuffer, destX: number, destY: number): void { const bgColor = RGBA.fromValues(0, 0, 0, 1); // Process two vertical pixels per terminal cell for (let py = 0; py < this.height; py += 2) { for (let px = 0; px < this.width; px++) { const topIdx = this.getIndex(px, py); const bottomIdx = this.getIndex(px, py + 1); const topA = this.pixels[topIdx + 3]; const bottomA = this.pixels[bottomIdx + 3]; // Skip fully transparent cells if (topA === 0 && bottomA === 0) continue; // Convert to RGBA (0-1 range) const topColor = topA > 0 ? RGBA.fromValues( this.pixels[topIdx] / 255, this.pixels[topIdx + 1] / 255, this.pixels[topIdx + 2] / 255, topA / 255, ) : bgColor; const bottomColor = bottomA > 0 ? RGBA.fromValues( this.pixels[bottomIdx] / 255, this.pixels[bottomIdx + 1] / 255, this.pixels[bottomIdx + 2] / 255, bottomA / 255, ) : bgColor; // ▀ = upper half block (fg = top, bg = bottom) buffer.setCellWithAlphaBlending( destX + px, destY + Math.floor(py / 2), '▀', topColor, bottomColor, ); } } } // ============ Static Helpers ============ /** * Create an empty image */ static createImage(width: number, height: number): GameImage { return { width, height, pixels: new Uint8Array(width * height * 4), }; } /** * Create an image from raw RGBA pixel data */ static imageFromPixels(width: number, height: number, pixels: Uint8Array): GameImage { return {width, height, pixels}; } /** * Create an image from a simple string art format * Each character maps to a color in the palette * Example: * const palette = { '#': [255,0,0,255], '.': [0,0,0,0] } * const art = ['..#..', '.###.', '..#..'] */ static imageFromArt( art: string[], palette: Record<string, [number, number, number, number]>, ): GameImage { const height = art.length; const width = Math.max(...art.map((row) => row.length)); const pixels = new Uint8Array(width * height * 4); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const char = art[y][x] || ' '; const color = palette[char] || [0, 0, 0, 0]; const idx = (y * width + x) * 4; pixels[idx] = color[0]; pixels[idx + 1] = color[1]; pixels[idx + 2] = color[2]; pixels[idx + 3] = color[3]; } } return {width, height, pixels}; } drawTile( tileMap: TileMap, tileName: string, _x: number, _y: number, flipX: boolean, flipY: boolean, ) { const pixels = tileMap.getTilePixels(tileName); if (!pixels) return; this.drawPixels(pixels, flipX, flipY, _x, _y); } private drawPixels(pixels: RGBA[], flipX: boolean, flipY: boolean, _x: number, _y: number) { for (let x = 0; x < TILE_SIZE; x++) { for (let y = 0; y < TILE_SIZE; y++) { const pixelColor = pixels[y * TILE_SIZE + x]; if (pixelColor && pixelColor.a > 0) { const drawX = flipX ? TILE_SIZE - 1 - x : x; // Fixed: flip logic was inverted const drawY = flipY ? TILE_SIZE - 1 - y : y; // Fixed: flip logic was inverted this.setPixel(_x + drawX, _y + drawY, pixelColor); } } } } drawTileRotated( tileMap: TileMap, tileName: string, _x: number, _y: number, rotation: number, flipX: boolean, flipY: boolean, ) { const pixels = tileMap.getTilePixels(tileName); if (!pixels) return; const centerX = TILE_SIZE / 2; const centerY = TILE_SIZE / 2; const result: RGBA[] = []; for (let x = 0; x < TILE_SIZE * TILE_SIZE; x++) { result[x] = RGBA.fromValues(0, 0, 0, 0); } for (let py = 0; py < TILE_SIZE; py++) { for (let px = 0; px < TILE_SIZE; px++) { // Calculate rotated position const relX = px - centerX; const relY = py - centerY; // Apply rotation const cos = Math.cos(rotation); const sin = Math.sin(rotation); const rotX = relX * cos - relY * sin; const rotY = relX * sin + relY * cos; // Get source pixel from rotated coordinates const srcX = Math.round(rotX + centerX); const srcY = Math.round(rotY + centerY); if (srcX >= 0 && srcX < TILE_SIZE && srcY >= 0 && srcY < TILE_SIZE) { const srcPixelX = flipX ? TILE_SIZE - 1 - srcX : srcX; const pixelIndex = srcY * TILE_SIZE + srcPixelX; const color = pixels[pixelIndex] || RGBA.fromValues(0, 0, 0, 0); const targetIndex = py * TILE_SIZE + px; result[targetIndex] = color; // Store pixel in result array } } } this.drawPixels(result, flipX, flipY, _x, _y); } drawCanvas(layer: PixelCanvas) { for (let py = 0; py < layer.height; py++) { for (let px = 0; px < layer.width; px++) { const [r, g, b, a] = layer.getPixel(px, py); if (a > 0) { this.setPixel(px, py, RGBA.fromValues(r, g, b, a)); } } } } }