shellquest
Version:
Terminal-based procedurally generated dungeon crawler
580 lines (503 loc) • 19 kB
text/typescript
/**
* 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,
};
}