UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

391 lines (341 loc) 13.1 kB
/** * LightingRenderer - Applies lighting effects to the final rendered output * * Works by modifying the pixel data after all layers have been composited, * darkening pixels based on a light map computed by the LightingSystem. * * Features: * - Banding: Quantizes brightness into discrete levels for retro look * - Dithering: Applies ordered dithering at band transitions * - Warm tinting: Applies orange/amber color shift to lit areas */ import type {PixelCanvas} from '../drawing/pixelCanvas.ts'; import type {LightingSystem, LightingConfig} from './LightingSystem.ts'; // ============================================================================= // DITHERING PATTERNS // ============================================================================= // 4x4 Bayer matrix for ordered dithering (normalized to 0-1) const BAYER_4X4 = [ [0 / 16, 8 / 16, 2 / 16, 10 / 16], [12 / 16, 4 / 16, 14 / 16, 6 / 16], [3 / 16, 11 / 16, 1 / 16, 9 / 16], [15 / 16, 7 / 16, 13 / 16, 5 / 16], ]; // 8x8 Bayer matrix for finer dithering const BAYER_8X8 = [ [0 / 64, 32 / 64, 8 / 64, 40 / 64, 2 / 64, 34 / 64, 10 / 64, 42 / 64], [48 / 64, 16 / 64, 56 / 64, 24 / 64, 50 / 64, 18 / 64, 58 / 64, 26 / 64], [12 / 64, 44 / 64, 4 / 64, 36 / 64, 14 / 64, 46 / 64, 6 / 64, 38 / 64], [60 / 64, 28 / 64, 52 / 64, 20 / 64, 62 / 64, 30 / 64, 54 / 64, 22 / 64], [3 / 64, 35 / 64, 11 / 64, 43 / 64, 1 / 64, 33 / 64, 9 / 64, 41 / 64], [51 / 64, 19 / 64, 59 / 64, 27 / 64, 49 / 64, 17 / 64, 57 / 64, 25 / 64], [15 / 64, 47 / 64, 7 / 64, 39 / 64, 13 / 64, 45 / 64, 5 / 64, 37 / 64], [63 / 64, 31 / 64, 55 / 64, 23 / 64, 61 / 64, 29 / 64, 53 / 64, 21 / 64], ]; /** * Get dither threshold for a pixel position using 8x8 Bayer matrix */ function getDitherThreshold(x: number, y: number): number { return BAYER_8X8[y & 7][x & 7]; } // ============================================================================= // BANDING HELPERS // ============================================================================= /** * Quantize a brightness value into discrete bands */ function quantizeBrightness(brightness: number, bandCount: number): number { if (brightness <= 0) return 0; if (brightness >= 1) return 1; // Quantize to bands const band = Math.floor(brightness * bandCount); return band / (bandCount - 1); } /** * Apply dithering to smooth band transitions * Returns the final quantized brightness with dithering applied */ function applyDitheredBanding( brightness: number, x: number, y: number, bandCount: number, ditherStrength: number, ): number { if (brightness <= 0) return 0; if (brightness >= 1) return 1; // Get the current and next band levels const scaledBrightness = brightness * (bandCount - 1); const lowerBand = Math.floor(scaledBrightness); const upperBand = Math.min(lowerBand + 1, bandCount - 1); // How far between bands (0-1) const bandFraction = scaledBrightness - lowerBand; // Get dither threshold for this pixel const threshold = getDitherThreshold(x, y); // Apply dithering at band edges // The dither strength controls how wide the dithered transition zone is const ditherZone = ditherStrength; let selectedBand: number; if (bandFraction < 0.5 - ditherZone / 2) { // Firmly in lower band selectedBand = lowerBand; } else if (bandFraction > 0.5 + ditherZone / 2) { // Firmly in upper band selectedBand = upperBand; } else { // In the dither zone - use threshold to decide const normalizedFraction = (bandFraction - (0.5 - ditherZone / 2)) / ditherZone; selectedBand = normalizedFraction > threshold ? upperBand : lowerBand; } return selectedBand / (bandCount - 1); } /** * Apply custom threshold-based banding with dithering * thresholds array defines brightness cutoffs (descending order) * e.g., [0.7, 0.5] for 3 bands: >0.7 = bright, 0.5-0.7 = mid, <0.5 = dark */ function applyThresholdBanding( brightness: number, x: number, y: number, thresholds: number[], ditherStrength: number, bandBrightness: number[], // brightness value for each band ): number { if (brightness >= 1) return 1; // Find which band this brightness falls into let bandIndex = thresholds.length; // Default to darkest band for (let i = 0; i < thresholds.length; i++) { if (brightness >= thresholds[i]) { bandIndex = i; break; } } // Get the brightness values for current and adjacent bands const currentBandBrightness = bandBrightness[bandIndex]; // Check if we're near a threshold for dithering const threshold = getDitherThreshold(x, y); const ditherRange = ditherStrength * 0.15; // How close to threshold to apply dithering for (let i = 0; i < thresholds.length; i++) { const diff = brightness - thresholds[i]; if (Math.abs(diff) < ditherRange) { // Near this threshold - apply dithering const normalizedDiff = (diff + ditherRange) / (2 * ditherRange); if (normalizedDiff > threshold) { return bandBrightness[i]; // Brighter band } else { return bandBrightness[i + 1]; // Darker band } } } return currentBandBrightness; } // ============================================================================= // LIGHTING CONFIG FOR RENDERER // ============================================================================= export interface LightingRenderConfig { warmTint: boolean; bandingEnabled: boolean; bandCount: number; ditherEnabled: boolean; ditherStrength: number; // Custom band thresholds - brightness values (0-1) where bands transition // For 3 bands: [0.6, 0.2] means bright band > 0.6, mid band 0.2-0.6, dark band < 0.2 // If not provided, bands are evenly distributed bandThresholds?: number[]; } const DEFAULT_RENDER_CONFIG: LightingRenderConfig = { warmTint: true, bandingEnabled: true, bandCount: 3, ditherEnabled: true, ditherStrength: 0.1, bandThresholds: [0.6, 0.5], }; /** * Apply lighting to a PixelCanvas * * @param canvas The canvas to modify (should have all layers composited) * @param lightMap RGB brightness values (0-1) for each pixel * @param config Rendering configuration for banding, dithering, tinting */ export function applyLighting( canvas: PixelCanvas, lightMap: {r: Float32Array; g: Float32Array; b: Float32Array}, config: Partial<LightingRenderConfig> = {}, ): void { const cfg = {...DEFAULT_RENDER_CONFIG, ...config}; const width = canvas.width; const height = canvas.height; for (let py = 0; py < height; py++) { for (let px = 0; px < width; px++) { const idx = py * width + px; let brightnessR = lightMap.r[idx] ?? 0; let brightnessG = lightMap.g[idx] ?? 0; let brightnessB = lightMap.b[idx] ?? 0; // Apply banding and dithering if enabled (apply to each channel) if (cfg.bandingEnabled) { if (cfg.bandThresholds && cfg.bandThresholds.length > 0) { const bandBrightness = [1.0, 0.5, 0.15]; brightnessR = applyThresholdBanding( brightnessR, px, py, cfg.bandThresholds, cfg.ditherStrength, bandBrightness, ); brightnessG = applyThresholdBanding( brightnessG, px, py, cfg.bandThresholds, cfg.ditherStrength, bandBrightness, ); brightnessB = applyThresholdBanding( brightnessB, px, py, cfg.bandThresholds, cfg.ditherStrength, bandBrightness, ); } else if (cfg.ditherEnabled) { brightnessR = applyDitheredBanding(brightnessR, px, py, cfg.bandCount, cfg.ditherStrength); brightnessG = applyDitheredBanding(brightnessG, px, py, cfg.bandCount, cfg.ditherStrength); brightnessB = applyDitheredBanding(brightnessB, px, py, cfg.bandCount, cfg.ditherStrength); } else { brightnessR = quantizeBrightness(brightnessR, cfg.bandCount); brightnessG = quantizeBrightness(brightnessG, cfg.bandCount); brightnessB = quantizeBrightness(brightnessB, cfg.bandCount); } } // Skip fully lit pixels (all channels at max) if (brightnessR >= 1.0 && brightnessG >= 1.0 && brightnessB >= 1.0) continue; // Get current pixel color const [r, g, b, a] = canvas.getPixel(px, py); // Skip transparent pixels if (a === 0) continue; // Apply per-channel brightness (colored lighting!) let newR: number; let newG: number; let newB: number; if (cfg.warmTint) { // Warm tint modifier on top of colored light const avgBrightness = (brightnessR + brightnessG + brightnessB) / 3; const darkness = 1 - avgBrightness; const redMult = 1.0 + darkness * 0.2; const greenMult = 1.0 - darkness * 0.15; const blueMult = 1.0 - darkness * 0.4; newR = Math.round(r * brightnessR * Math.min(redMult, 1.2)); newG = Math.round(g * brightnessG * Math.max(greenMult, 0.6)); newB = Math.round(b * brightnessB * Math.max(blueMult, 0.3)); } else { // Direct colored lighting - each channel applies its own brightness newR = Math.round(r * brightnessR); newG = Math.round(g * brightnessG); newB = Math.round(b * brightnessB); } // Clamp values and set pixel directly setPixelDirect(canvas, px, py, newR, newG, newB, a); } } } /** * Set a pixel directly on the canvas (bypasses translation) * This is a workaround that accesses the internal pixel buffer */ function setPixelDirect( canvas: PixelCanvas, x: number, y: number, r: number, g: number, b: number, a: number, ): void { // Access the internal pixels array through the public interface // We use a typed approach that works with the existing canvas const currentColor = canvas.getPixel(x, y); if (currentColor[3] === 0) return; // Skip transparent // Use save/restore to temporarily reset translation canvas.save(); canvas.resetTransform(); // Create a temporary RGBA-like object for setPixel // This works because setPixel accepts an RGBA parameter const color = { r: Math.max(0, Math.min(255, r)), g: Math.max(0, Math.min(255, g)), b: Math.max(0, Math.min(255, b)), a: Math.max(0, Math.min(255, a)), }; canvas.setPixel(x, y, color as any); canvas.restore(); } /** * Compute and apply lighting in one step */ export function renderWithLighting( canvas: PixelCanvas, lightingSystem: LightingSystem, cameraX: number, cameraY: number, config: Partial<LightingRenderConfig> = {}, ): void { const lightMap = lightingSystem.computeLightMap(canvas.width, canvas.height, cameraX, cameraY); // Merge lighting system config with render config const systemConfig = lightingSystem.getConfig(); const mergedConfig: Partial<LightingRenderConfig> = { bandingEnabled: systemConfig.bandingEnabled, bandCount: systemConfig.bandCount, ditherEnabled: systemConfig.ditherEnabled, ditherStrength: systemConfig.ditherStrength, ...config, }; applyLighting(canvas, lightMap, mergedConfig); } /** * Apply a simple darkness overlay without a full light map * Useful for testing or performance-constrained scenarios */ export function applySimpleDarkness( canvas: PixelCanvas, ambientLevel: number, playerX: number, playerY: number, lightRadius: number, cameraX: number, cameraY: number, ): void { const width = canvas.width; const height = canvas.height; // Player position in screen coordinates const playerScreenX = playerX - cameraX; const playerScreenY = playerY - cameraY; const radiusSq = lightRadius * lightRadius; for (let py = 0; py < height; py++) { for (let px = 0; px < width; px++) { // Calculate distance from player const dx = px - playerScreenX; const dy = py - playerScreenY; const distSq = dx * dx + dy * dy; let brightness: number; if (distSq <= radiusSq) { // Within light radius const dist = Math.sqrt(distSq); const falloff = Math.pow(1 - dist / lightRadius, 1.5); brightness = ambientLevel + (1 - ambientLevel) * falloff; } else { brightness = ambientLevel; } if (brightness >= 1.0) continue; const [r, g, b, a] = canvas.getPixel(px, py); if (a === 0) continue; const newR = Math.round(r * brightness); const newG = Math.round(g * brightness); const newB = Math.round(b * brightness); setPixelDirect(canvas, px, py, newR, newG, newB, a); } } }