Add emissive torch lighting with occlusion#8
Conversation
There was a problem hiding this comment.
Pull Request Overview
This PR introduces a dynamic lighting system to the game by adding a new LightSource component and implementing emissive light rendering with shadow calculations. The system supports configurable light properties including radius, intensity, color, flicker effects, and particle emitters, enabling atmospheric effects like torch flames.
- Added
LightSourcecomponent with validation for light properties (radius, intensity, color, flicker, style, emitter) - Implemented emissive light rendering with shadow polygons calculated via raycasting
- Integrated particle effects for torch emitters with dynamic flicker animations
Reviewed Changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/rules/components/index.js | Exports the new LightSource component |
| src/rules/components/LightSource.js | Defines the LightSource component with property validation |
| src/main/scene/demoScene.js | Adds a torch entity to the demo scene using LightSource |
| src/display/palette/base.js | Adds torch glyph and colors to the palette |
| src/display/lighting/sources/index.js | Collects light sources from world view including player, status effects, and LightSource components |
| src/display/lighting/renderEmissiveLights.js | Implements shadow-casting light rendering with radial gradients |
| src/bridge/schema/worldView.js | Extends WorldView to include emissive light data |
| src/main.js | Integrates lighting and particle systems into the render loop |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (rec.color != null && typeof rec.color !== "string") { | ||
| throw new Error(`LightSource.color must be a string or null (got ${typeof rec.color})`); | ||
| } | ||
| if (!Number.isFinite(rec.flicker ?? 0) || rec.flicker < 0) { |
There was a problem hiding this comment.
The validation logic for flicker is inconsistent with radius and intensity. Lines 24-30 first convert the value to a number before checking, but line 35 uses the nullish coalescing operator (rec.flicker ?? 0) without converting rec.flicker to a number first. This could fail validation if rec.flicker is a string like "0.5". For consistency, use const flicker = Number(rec.flicker ?? 0); and then check if (!Number.isFinite(flicker) || flicker < 0).
| if (!Number.isFinite(rec.flicker ?? 0) || rec.flicker < 0) { | |
| const flicker = Number(rec.flicker ?? 0); | |
| if (!Number.isFinite(flicker) || flicker < 0) { |
| radius: Number(light.radius) || 0, | ||
| intensity: Number(light.intensity) || 0, | ||
| color: typeof light.color === "string" ? light.color : null, | ||
| flicker: Number(light.flicker) || 0, | ||
| style: typeof light.style === "string" ? light.style : null, | ||
| emitter: typeof light.emitter === "string" ? light.emitter : null, | ||
| }; | ||
| _emissiveRecs.set(id, lrec); | ||
| } else { | ||
| lrec.x = pos.x; | ||
| lrec.y = pos.y; | ||
| lrec.radius = Number(light.radius) || 0; | ||
| lrec.intensity = Number(light.intensity) || 0; | ||
| lrec.color = typeof light.color === "string" ? light.color : null; | ||
| lrec.flicker = Number(light.flicker) || 0; |
There was a problem hiding this comment.
The conversion Number(light.flicker) || 0 will result in 0 for any falsy value including 0 itself, which is correct. However, this differs from lines 215-216 and 226-227 which use the same pattern. For consistency and clarity, consider using the nullish coalescing operator (??) throughout to distinguish between explicit 0 values and missing values, e.g., Number(light.flicker ?? 0).
| x: l.x, | ||
| y: l.y, | ||
| radius, | ||
| intensity: Math.max(0, Number(l.intensity) || 0.8), |
There was a problem hiding this comment.
The fallback value 0.8 in Number(l.intensity) || 0.8 is inconsistent with the LightSource component's default intensity of 1. If the intention is to provide a fallback for missing values, consider using Number(l.intensity) ?? 0.8 to distinguish between explicit 0 and missing values, or align the fallback with the component default.
| intensity: Math.max(0, Number(l.intensity) || 0.8), | |
| intensity: Math.max(0, Number(l.intensity ?? 1)), |
| function hashToUnit(key) { | ||
| const s = String(key); | ||
| let h = 2166136261 >>> 0; | ||
| for (let i = 0; i < s.length; i++) { | ||
| h ^= s.charCodeAt(i); | ||
| h = Math.imul(h, 16777619); | ||
| } | ||
| return (h >>> 0) / 4294967295; | ||
| } |
There was a problem hiding this comment.
The hashToUnit function is duplicated in both src/main.js (lines 727-735) and src/display/lighting/renderEmissiveLights.js (lines 180-188) with identical implementations. This duplication violates the DRY principle and makes maintenance harder. Consider extracting this function to a shared utility module.
| function parseRgb(hex) { | ||
| if (typeof hex !== "string") return { r: 255, g: 180, b: 120 }; | ||
| let h = hex.trim(); | ||
| if (h.startsWith("#")) h = h.slice(1); | ||
| if (h.length === 3) { | ||
| return { | ||
| r: parseInt(h[0] + h[0], 16) || 255, | ||
| g: parseInt(h[1] + h[1], 16) || 180, | ||
| b: parseInt(h[2] + h[2], 16) || 120, | ||
| }; | ||
| } | ||
| if (h.length === 6) { | ||
| return { | ||
| r: parseInt(h.slice(0, 2), 16) || 255, | ||
| g: parseInt(h.slice(2, 4), 16) || 180, | ||
| b: parseInt(h.slice(4, 6), 16) || 120, | ||
| }; | ||
| } | ||
| return { r: 255, g: 180, b: 120 }; | ||
| } | ||
|
|
There was a problem hiding this comment.
The parseRgb function in src/main.js (lines 706-725) and the parseColor function in src/display/lighting/renderEmissiveLights.js (lines 190-207) are nearly identical except for different fallback values. This duplication should be consolidated into a shared utility function to improve maintainability and reduce code redundancy.
| function parseRgb(hex) { | |
| if (typeof hex !== "string") return { r: 255, g: 180, b: 120 }; | |
| let h = hex.trim(); | |
| if (h.startsWith("#")) h = h.slice(1); | |
| if (h.length === 3) { | |
| return { | |
| r: parseInt(h[0] + h[0], 16) || 255, | |
| g: parseInt(h[1] + h[1], 16) || 180, | |
| b: parseInt(h[2] + h[2], 16) || 120, | |
| }; | |
| } | |
| if (h.length === 6) { | |
| return { | |
| r: parseInt(h.slice(0, 2), 16) || 255, | |
| g: parseInt(h.slice(2, 4), 16) || 180, | |
| b: parseInt(h.slice(4, 6), 16) || 120, | |
| }; | |
| } | |
| return { r: 255, g: 180, b: 120 }; | |
| } | |
| function parseHexColor(hex, fallback) { | |
| if (typeof hex !== "string") return { ...fallback }; | |
| let h = hex.trim(); | |
| if (h.startsWith("#")) h = h.slice(1); | |
| if (h.length === 3) { | |
| return { | |
| r: parseInt(h[0] + h[0], 16) || fallback.r, | |
| g: parseInt(h[1] + h[1], 16) || fallback.g, | |
| b: parseInt(h[2] + h[2], 16) || fallback.b, | |
| }; | |
| } | |
| if (h.length === 6) { | |
| return { | |
| r: parseInt(h.slice(0, 2), 16) || fallback.r, | |
| g: parseInt(h.slice(2, 4), 16) || fallback.g, | |
| b: parseInt(h.slice(4, 6), 16) || fallback.b, | |
| }; | |
| } | |
| return { ...fallback }; | |
| } | |
| function parseRgb(hex) { | |
| return parseHexColor(hex, { r: 255, g: 180, b: 120 }); | |
| } |
| r: parseInt(h[0] + h[0], 16) || 255, | ||
| g: parseInt(h[1] + h[1], 16) || 180, | ||
| b: parseInt(h[2] + h[2], 16) || 120, | ||
| }; | ||
| } | ||
| if (h.length === 6) { | ||
| return { | ||
| r: parseInt(h.slice(0, 2), 16) || 255, | ||
| g: parseInt(h.slice(2, 4), 16) || 180, | ||
| b: parseInt(h.slice(4, 6), 16) || 120, |
There was a problem hiding this comment.
When parsing hex colors fails, parseInt returns NaN, but the code uses || 255/180/120 to provide fallbacks. However, NaN || 255 evaluates to 255, which masks parsing errors. Since the main return statement (line 724) already handles invalid inputs, these fallback values in the parseInt calls (lines 712-714, 719-721) will never be used and are misleading. Consider removing them or using proper NaN checks.
| r: parseInt(h[0] + h[0], 16) || 255, | |
| g: parseInt(h[1] + h[1], 16) || 180, | |
| b: parseInt(h[2] + h[2], 16) || 120, | |
| }; | |
| } | |
| if (h.length === 6) { | |
| return { | |
| r: parseInt(h.slice(0, 2), 16) || 255, | |
| g: parseInt(h.slice(2, 4), 16) || 180, | |
| b: parseInt(h.slice(4, 6), 16) || 120, | |
| r: parseInt(h[0] + h[0], 16), | |
| g: parseInt(h[1] + h[1], 16), | |
| b: parseInt(h[2] + h[2], 16), | |
| }; | |
| } | |
| if (h.length === 6) { | |
| return { | |
| r: parseInt(h.slice(0, 2), 16), | |
| g: parseInt(h.slice(2, 4), 16), | |
| b: parseInt(h.slice(4, 6), 16), |
| const b = parseInt(h[2] + h[2], 16); | ||
| return { r, g, b }; | ||
| } | ||
| if (h.length === 6) { | ||
| const r = parseInt(h.slice(0, 2), 16); | ||
| const g = parseInt(h.slice(2, 4), 16); | ||
| const b = parseInt(h.slice(4, 6), 16); |
There was a problem hiding this comment.
When parsing hex colors fails, parseInt returns NaN, which will be assigned to r, g, or b. The function should handle NaN values properly by either falling back to the default color values or validating the result. Currently, NaN values could propagate to the caller, potentially causing rendering issues.
| const b = parseInt(h[2] + h[2], 16); | |
| return { r, g, b }; | |
| } | |
| if (h.length === 6) { | |
| const r = parseInt(h.slice(0, 2), 16); | |
| const g = parseInt(h.slice(2, 4), 16); | |
| const b = parseInt(h.slice(4, 6), 16); | |
| const b = parseInt(h[2] + h[2], 16); | |
| if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) { | |
| return { r: 255, g: 200, b: 160 }; | |
| } | |
| return { r, g, b }; | |
| } | |
| if (h.length === 6) { | |
| const r = parseInt(h.slice(0, 2), 16); | |
| const g = parseInt(h.slice(2, 4), 16); | |
| const b = parseInt(h.slice(4, 6), 16); | |
| if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) { | |
| return { r: 255, g: 200, b: 160 }; | |
| } |
Summary
Testing
https://chatgpt.com/codex/tasks/task_e_690d1bfdd5008333b404d482ae7c7004