UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

580 lines (503 loc) 19 kB
/** * LightingSystem - Dynamic lighting with flickering torches * * Features: * - Point light sources with radius falloff * - Flickering effect using perlin noise for realistic candle simulation * - Darkness overlay for unlit areas * - Performance optimized for visible viewport only */ import {TILE_SIZE} from '../TileMap.ts'; import {getNoise2D} from '../level/dungeon/DungeonUtils.ts'; // ============================================================================= // TYPES // ============================================================================= export interface LightSource { id: string; x: number; // Position in pixels y: number; // Position in pixels radius: number; // Light radius in tiles intensity: number; // Base intensity (0-1) color: {r: number; g: number; b: number}; // Light color (0-255) flicker: boolean; // Whether this light flickers flickerSpeed: number; // How fast the flicker (multiplier for time) flickerIntensity: number; // How much the light varies (0-1) enabled: boolean; // Dual orbital light effect - two lights orbiting the center orbital: boolean; // Whether to render as two orbiting sub-lights orbitalRadius: number; // Distance from center each sub-light orbits (in pixels) orbitalSpeed: number; // Speed of orbit (radians per second) } export interface LightingConfig { ambientLight: number; // Base visibility in darkness (0-1), default 0.1 maxBrightness: number; // Maximum brightness cap (0-1), default 1.0 falloffExponent: number; // Controls how light fades (higher = sharper), default 1.5 globalFlickerSync: boolean; // Whether all torches flicker in sync hardEdge: boolean; // If true, light is binary (full brightness inside radius, dark outside) edgeThreshold: number; // For hard edge: what percentage of radius is fully lit (0-1) // Banding settings for retro look bandingEnabled: boolean; // Whether to quantize light into discrete bands bandCount: number; // Number of brightness bands (e.g., 5 = 5 distinct levels) ditherEnabled: boolean; // Whether to apply dithering at band edges ditherStrength: number; // How much dithering to apply (0-1) } // ============================================================================= // DEFAULT CONFIGURATIONS // ============================================================================= export const DEFAULT_LIGHTING_CONFIG: LightingConfig = { ambientLight: 0.15, maxBrightness: 1.0, falloffExponent: 1.5, // Lower = steeper falloff, makes middle band ~20% of radius globalFlickerSync: true, hardEdge: false, // Use smooth falloff for banding edgeThreshold: 0.95, // 95% of radius is fully lit, then sharp dropoff // Retro banding settings bandingEnabled: true, bandCount: 3, // 3 bands: bright center, mid band, dark outer ditherEnabled: true, ditherStrength: 0.1, // Subtle dithering at band edges }; export const TORCH_LIGHT_CONFIG = { radius: 7, // 2x radius intensity: 0.55, color: {r: 255, g: 0, b: 0}, flicker: false, flickerSpeed: 1, flickerIntensity: 0.15, orbital: true, orbitalRadius: 20, // pixels from center orbitalSpeed: 0.6, // slow orbit }; export const PLAYER_TORCH_CONFIG = { radius: 13, // Light radius in tiles intensity: 1.4, color: {r: 255, g: 255, b: 255}, // Warm orange torch light flicker: true, flickerSpeed: 0.1, flickerIntensity: 0.1, // Subtle flicker orbital: true, orbitalRadius: 20, orbitalSpeed: 0.5, }; // Spell projectile light configs export const SPELL_LIGHT_CONFIGS: Record< string, { radius: number; intensity: number; color: {r: number; g: number; b: number}; flicker: boolean; flickerSpeed: number; flickerIntensity: number; orbital: boolean; orbitalRadius: number; orbitalSpeed: number; } > = { fireball: { radius: 2, intensity: 1.0, color: {r: 255, g: 150, b: 50}, // Bright orange flicker: true, flickerSpeed: 5, flickerIntensity: 0.3, orbital: false, orbitalRadius: 0, orbitalSpeed: 0, }, iceblast: { radius: 2, intensity: 0.8, color: {r: 150, g: 200, b: 255}, // Cool blue flicker: true, flickerSpeed: 3, flickerIntensity: 0.2, orbital: false, orbitalRadius: 0, orbitalSpeed: 0, }, lightning: { radius: 3, intensity: 1.2, color: {r: 200, g: 200, b: 255}, // Bright white-blue flicker: true, flickerSpeed: 10, flickerIntensity: 0.5, orbital: false, orbitalRadius: 0, orbitalSpeed: 0, }, poison: { radius: 2, intensity: 0.7, color: {r: 100, g: 255, b: 100}, // Sickly green flicker: true, flickerSpeed: 2, flickerIntensity: 0.15, orbital: false, orbitalRadius: 0, orbitalSpeed: 0, }, }; // ============================================================================= // LIGHTING SYSTEM // ============================================================================= export class LightingSystem { private lightSources: Map<string, LightSource> = new Map(); private config: LightingConfig; private time: number = 0; // Cached light map for current frame (RGB values per pixel) private lightMapR: Float32Array | null = null; private lightMapG: Float32Array | null = null; private lightMapB: Float32Array | null = null; private lightMapWidth: number = 0; private lightMapHeight: number = 0; private lastCameraX: number = -1; private lastCameraY: number = -1; // Pre-computed flicker values (updated each frame) private flickerValues: Map<string, number> = new Map(); constructor(config: Partial<LightingConfig> = {}) { this.config = {...DEFAULT_LIGHTING_CONFIG, ...config}; } // =========================================================================== // LIGHT SOURCE MANAGEMENT // =========================================================================== addLight( source: Omit<LightSource, 'enabled' | 'orbital' | 'orbitalRadius' | 'orbitalSpeed'> & { enabled?: boolean; orbital?: boolean; orbitalRadius?: number; orbitalSpeed?: number; }, ): LightSource { const fullSource: LightSource = { ...source, enabled: source.enabled ?? true, orbital: source.orbital ?? true, orbitalRadius: source.orbitalRadius ?? 8, orbitalSpeed: source.orbitalSpeed ?? 0.5, }; this.lightSources.set(source.id, fullSource); return fullSource; } removeLight(id: string): void { this.lightSources.delete(id); } getLight(id: string): LightSource | undefined { return this.lightSources.get(id); } updateLightPosition(id: string, x: number, y: number): void { const light = this.lightSources.get(id); if (light) { light.x = x; light.y = y; } } setLightEnabled(id: string, enabled: boolean): void { const light = this.lightSources.get(id); if (light) { light.enabled = enabled; } } clearLights(): void { this.lightSources.clear(); } // =========================================================================== // UPDATE (called each frame) // =========================================================================== update(deltaTime: number): void { this.time += deltaTime / 1000; // Convert to seconds // Update flicker values for all lights for (const [id, light] of this.lightSources) { if (light.flicker && light.enabled) { this.flickerValues.set(id, this.computeFlicker(light)); } } // Invalidate light map cache this.lightMapR = null; this.lightMapG = null; this.lightMapB = null; } // =========================================================================== // FLICKER COMPUTATION // =========================================================================== private computeFlicker(light: LightSource): number { // Use multiple noise layers for realistic candle flicker const t = this.time * light.flickerSpeed; // Use light position as seed for unique per-light flicker const seedX = light.x * 0.001; const seedY = light.y * 0.001; // Layer 1: Slow base variation const slow = getNoise2D(t * 0.5 + seedX, seedY, 1.0) * 2 - 1; // Layer 2: Medium flutter const medium = getNoise2D(t * 2.0 + seedX + 100, seedY + 100, 1.0) * 2 - 1; // Layer 3: Fast flicker const fast = getNoise2D(t * 8.0 + seedX + 200, seedY + 200, 1.0) * 2 - 1; // Combine: mostly slow with some medium and a touch of fast const combined = slow * 0.5 + medium * 0.35 + fast * 0.15; // Scale by flicker intensity and return as multiplier (0.85 - 1.15 range typically) return 1.0 + combined * light.flickerIntensity; } // =========================================================================== // LIGHT MAP COMPUTATION // =========================================================================== /** * Compute the light map for the visible viewport * Returns RGB brightness values (0-1) for each pixel in the viewport */ computeLightMap( viewportWidth: number, viewportHeight: number, cameraX: number, cameraY: number, ): {r: Float32Array; g: Float32Array; b: Float32Array} { // Check cache validity if ( this.lightMapR !== null && this.lightMapG !== null && this.lightMapB !== null && this.lightMapWidth === viewportWidth && this.lightMapHeight === viewportHeight && this.lastCameraX === cameraX && this.lastCameraY === cameraY ) { return {r: this.lightMapR, g: this.lightMapG, b: this.lightMapB}; } // Create new light maps for RGB channels const mapSize = viewportWidth * viewportHeight; const lightMapR = new Float32Array(mapSize); const lightMapG = new Float32Array(mapSize); const lightMapB = new Float32Array(mapSize); // Fill with ambient light (neutral white ambient) lightMapR.fill(this.config.ambientLight); lightMapG.fill(this.config.ambientLight); lightMapB.fill(this.config.ambientLight); // Get lights that could affect the viewport const padding = TILE_SIZE * 10; // Extra padding for light radius const visibleLights = this.getVisibleLights( cameraX - padding, cameraY - padding, viewportWidth + padding * 2, viewportHeight + padding * 2, ); // Helper to add a single point light contribution to the map const addLightContribution = ( centerX: number, centerY: number, intensity: number, radiusPixels: number, colorR: number, colorG: number, colorB: number, ) => { // Normalize color (0-255 -> 0-1) const normR = colorR / 255; const normG = colorG / 255; const normB = colorB / 255; const radiusSq = radiusPixels * radiusPixels; const minX = Math.max(0, Math.floor(centerX - radiusPixels)); const maxX = Math.min(viewportWidth - 1, Math.ceil(centerX + radiusPixels)); const minY = Math.max(0, Math.floor(centerY - radiusPixels)); const maxY = Math.min(viewportHeight - 1, Math.ceil(centerY + radiusPixels)); for (let py = minY; py <= maxY; py++) { for (let px = minX; px <= maxX; px++) { const dx = px - centerX; const dy = py - centerY; const distSq = dx * dx + dy * dy; if (distSq > radiusSq) continue; const dist = Math.sqrt(distSq); const normalizedDist = dist / radiusPixels; let contribution: number; if (this.config.hardEdge) { if (normalizedDist <= this.config.edgeThreshold) { contribution = intensity; } else { const edgeFactor = (1 - normalizedDist) / (1 - this.config.edgeThreshold); contribution = intensity * edgeFactor; } } else { const falloff = Math.pow(1 - normalizedDist, this.config.falloffExponent); contribution = intensity * falloff; } const idx = py * viewportWidth + px; // Apply colored light contribution lightMapR[idx] = Math.min( this.config.maxBrightness, lightMapR[idx] + contribution * normR, ); lightMapG[idx] = Math.min( this.config.maxBrightness, lightMapG[idx] + contribution * normG, ); lightMapB[idx] = Math.min( this.config.maxBrightness, lightMapB[idx] + contribution * normB, ); } } }; // For each pixel in the viewport, accumulate light contribution for (const light of visibleLights) { if (!light.enabled) continue; const flickerMult = light.flicker ? (this.flickerValues.get(light.id) ?? 1.0) : 1.0; const effectiveIntensity = light.intensity * flickerMult; const radiusPixels = light.radius * TILE_SIZE; // Light center position in screen space const lightScreenX = light.x - cameraX; const lightScreenY = light.y - cameraY; if (light.orbital && light.orbitalRadius > 0) { // Dual orbital lights: two sub-lights orbit around the center // Each sub-light is at half intensity so total contribution is similar const angle = this.time * light.orbitalSpeed; const orbitX1 = Math.cos(angle) * light.orbitalRadius; const orbitY1 = Math.sin(angle) * light.orbitalRadius; // Second light is on the opposite side (180 degrees offset) const orbitX2 = -orbitX1; const orbitY2 = -orbitY1; const subIntensity = effectiveIntensity * 0.6; // Each sub-light at 60% for nice overlap addLightContribution( lightScreenX + orbitX1, lightScreenY + orbitY1, subIntensity, radiusPixels, light.color.r, light.color.g, light.color.b, ); addLightContribution( lightScreenX + orbitX2, lightScreenY + orbitY2, subIntensity, radiusPixels, light.color.r, light.color.g, light.color.b, ); } else { // Single light at center addLightContribution( lightScreenX, lightScreenY, effectiveIntensity, radiusPixels, light.color.r, light.color.g, light.color.b, ); } } // Cache the result this.lightMapR = lightMapR; this.lightMapG = lightMapG; this.lightMapB = lightMapB; this.lightMapWidth = viewportWidth; this.lightMapHeight = viewportHeight; this.lastCameraX = cameraX; this.lastCameraY = cameraY; return {r: lightMapR, g: lightMapG, b: lightMapB}; } /** * Get brightness at a specific screen position (0-1) - returns max of RGB channels */ getBrightnessAt(screenX: number, screenY: number): number { if (!this.lightMapR || !this.lightMapG || !this.lightMapB) return this.config.ambientLight; const x = Math.floor(screenX); const y = Math.floor(screenY); if (x < 0 || x >= this.lightMapWidth || y < 0 || y >= this.lightMapHeight) { return this.config.ambientLight; } const idx = y * this.lightMapWidth + x; return Math.max(this.lightMapR[idx], this.lightMapG[idx], this.lightMapB[idx]); } /** * Get RGB brightness at a specific screen position */ getColorAt(screenX: number, screenY: number): {r: number; g: number; b: number} { if (!this.lightMapR || !this.lightMapG || !this.lightMapB) { return { r: this.config.ambientLight, g: this.config.ambientLight, b: this.config.ambientLight, }; } const x = Math.floor(screenX); const y = Math.floor(screenY); if (x < 0 || x >= this.lightMapWidth || y < 0 || y >= this.lightMapHeight) { return { r: this.config.ambientLight, g: this.config.ambientLight, b: this.config.ambientLight, }; } const idx = y * this.lightMapWidth + x; return {r: this.lightMapR[idx], g: this.lightMapG[idx], b: this.lightMapB[idx]}; } // =========================================================================== // HELPERS // =========================================================================== private getVisibleLights(x: number, y: number, width: number, height: number): LightSource[] { const visible: LightSource[] = []; for (const light of this.lightSources.values()) { if (!light.enabled) continue; const radiusPixels = light.radius * TILE_SIZE; // Check if light's influence area intersects with the query area if ( light.x + radiusPixels >= x && light.x - radiusPixels <= x + width && light.y + radiusPixels >= y && light.y - radiusPixels <= y + height ) { visible.push(light); } } return visible; } // =========================================================================== // CONFIGURATION // =========================================================================== setAmbientLight(level: number): void { this.config.ambientLight = Math.max(0, Math.min(1, level)); } getConfig(): LightingConfig { return {...this.config}; } updateConfig(config: Partial<LightingConfig>): void { this.config = {...this.config, ...config}; // Invalidate cache this.lightMapR = null; this.lightMapG = null; this.lightMapB = null; } // =========================================================================== // DEBUG // =========================================================================== getLightCount(): number { return this.lightSources.size; } getEnabledLightCount(): number { let count = 0; for (const light of this.lightSources.values()) { if (light.enabled) count++; } return count; } } // ============================================================================= // FACTORY FUNCTIONS // ============================================================================= export function createTorchLight(id: string, x: number, y: number): Omit<LightSource, 'enabled'> { return { id, x, y, ...TORCH_LIGHT_CONFIG, }; } export function createPlayerTorchLight(x: number, y: number): Omit<LightSource, 'enabled'> { return { id: 'player-torch', x, y, ...PLAYER_TORCH_CONFIG, }; }