shellquest
Version:
Terminal-based procedurally generated dungeon crawler
611 lines (532 loc) • 17.8 kB
text/typescript
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));
}
}
}
}
}