Skip to content

Add emissive torch lighting with occlusion#8

Merged
PJensen merged 2 commits intomasterfrom
codex/implement-smooth-lighting-with-emissive-torches
Nov 7, 2025
Merged

Add emissive torch lighting with occlusion#8
PJensen merged 2 commits intomasterfrom
codex/implement-smooth-lighting-with-emissive-torches

Conversation

@PJensen
Copy link
Owner

@PJensen PJensen commented Nov 6, 2025

Summary

  • add a LightSource component and surface emissive data through the world view contract
  • render emissive lights with analytic occlusion, flicker, and optional particle emitters
  • seed the demo room with a wall torch and palette glyph updates for the new light source

Testing

  • npm test (fails: ERR_MODULE_NOT_FOUND for src/lib/ecs-js/index.js in test harness)

https://chatgpt.com/codex/tasks/task_e_690d1bfdd5008333b404d482ae7c7004

Copilot AI review requested due to automatic review settings November 6, 2025 22:19
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LightSource component 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) {
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
if (!Number.isFinite(rec.flicker ?? 0) || rec.flicker < 0) {
const flicker = Number(rec.flicker ?? 0);
if (!Number.isFinite(flicker) || flicker < 0) {

Copilot uses AI. Check for mistakes.
Comment on lines +215 to +229
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;
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
x: l.x,
y: l.y,
radius,
intensity: Math.max(0, Number(l.intensity) || 0.8),
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
intensity: Math.max(0, Number(l.intensity) || 0.8),
intensity: Math.max(0, Number(l.intensity ?? 1)),

Copilot uses AI. Check for mistakes.
Comment on lines 727 to 735
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;
}
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +706 to +726
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 };
}

Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 });
}

Copilot uses AI. Check for mistakes.
Comment on lines +712 to +721
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,
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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),

Copilot uses AI. Check for mistakes.
Comment on lines +197 to +203
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);
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 };
}

Copilot uses AI. Check for mistakes.
@PJensen PJensen merged commit a61dbf4 into master Nov 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants