Skip to content

Commit 2e142fb

Browse files
authored
Merge pull request #40 from lucent-lab/feature/live-batch-processing
feat: add 2d lighting helpers
2 parents 85268c0 + 01532c2 commit 2e142fb

9 files changed

Lines changed: 264 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ npm run size # Enforce bundle size budget
5353
- Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control.
5454
- Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more).
5555

56-
Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically.
56+
Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts`, `examples/lighting.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically.
5757

5858
## Contributing
5959
1. Fork the repository.

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@
6262
- **Systems for gameplay loops**
6363
- [x] Inventory system primitives (stacking, filtering, persistence hooks)
6464
- [x] Combat resolution helpers (cooldowns, damage formulas, status effects)
65-
- [ ] Quest/dialog state machine utilities
66-
- [ ] 2D lighting helpers (falloff, blending stubs)
65+
- [x] Quest/dialog state machine utilities
66+
- [x] 2D lighting helpers (falloff, blending stubs)
6767
- [ ] Wave spawner utilities for encounter pacing
6868
- [ ] Sound manager stubs (channel limiting, priority)
6969
- [ ] Input manager abstraction (keyboard/mouse/pad remapping)

docs/index.d.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,68 @@ export function createQuestMachine<
17751775
TContext extends Record<string, unknown>,
17761776
TEvent = unknown
17771777
>(options: QuestMachineOptions<TContext, TEvent>): QuestMachine<TContext, TEvent>;
1778+
1779+
/**
1780+
* Lighting falloff mode identifiers.
1781+
* Use for: controlling light intensity attenuation.
1782+
* Import: gameplay/lighting.ts
1783+
*/
1784+
export type FalloffMode = 'linear' | 'quadratic' | 'smoothstep';
1785+
1786+
/**
1787+
* Point light definition for lighting grids.
1788+
* Use for: positioning lights with radius and color.
1789+
* Import: gameplay/lighting.ts
1790+
*/
1791+
export interface PointLight {
1792+
x: number;
1793+
y: number;
1794+
radius: number;
1795+
intensity?: number;
1796+
falloff?: FalloffMode;
1797+
color?: [number, number, number];
1798+
}
1799+
1800+
/**
1801+
* Lighting grid configuration.
1802+
* Use for: computing lightmaps for tile-based scenes.
1803+
* Import: gameplay/lighting.ts
1804+
*/
1805+
export interface LightingGridOptions {
1806+
width: number;
1807+
height: number;
1808+
tileSize: number;
1809+
ambient?: number;
1810+
lights: ReadonlyArray<PointLight>;
1811+
obstacles?: (x: number, y: number) => boolean;
1812+
}
1813+
1814+
/**
1815+
* Lighting cell output containing intensity and blended color.
1816+
* Import: gameplay/lighting.ts
1817+
*/
1818+
export interface LightingCell {
1819+
light: number;
1820+
color: [number, number, number];
1821+
}
1822+
1823+
/**
1824+
* Lighting grid computation result.
1825+
* Import: gameplay/lighting.ts
1826+
*/
1827+
export interface LightingGridResult {
1828+
width: number;
1829+
height: number;
1830+
cells: LightingCell[];
1831+
}
1832+
1833+
/**
1834+
* Computes a lighting grid with point lights and ambient light.
1835+
* Use for: tile map lighting, fog-of-war, and shading overlays.
1836+
* Performance: O(width × height × lights).
1837+
* Import: gameplay/lighting.ts
1838+
*/
1839+
export function computeLightingGrid(options: LightingGridOptions): LightingGridResult;
17781840
/**
17791841
* Item insertion payload used by the inventory controller.
17801842
* Use for: adding items with quantity and metadata.

examples/lighting.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { computeLightingGrid } from '../src/index.js';
2+
3+
const lighting = computeLightingGrid({
4+
width: 5,
5+
height: 5,
6+
tileSize: 16,
7+
ambient: 0.2,
8+
lights: [
9+
{ x: 40, y: 40, radius: 64, intensity: 1, color: [1, 0.9, 0.7] },
10+
{ x: 80, y: 16, radius: 48, intensity: 0.8, color: [0.6, 0.8, 1] },
11+
],
12+
});
13+
14+
console.log(lighting);

src/gameplay/lighting.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
export type FalloffMode = 'linear' | 'quadratic' | 'smoothstep';
2+
3+
export interface PointLight {
4+
x: number;
5+
y: number;
6+
radius: number;
7+
intensity?: number;
8+
falloff?: FalloffMode;
9+
color?: [number, number, number];
10+
}
11+
12+
export interface LightingGridOptions {
13+
width: number;
14+
height: number;
15+
tileSize: number;
16+
ambient?: number;
17+
lights: ReadonlyArray<PointLight>;
18+
obstacles?: (x: number, y: number) => boolean;
19+
}
20+
21+
export interface LightingCell {
22+
light: number;
23+
color: [number, number, number];
24+
}
25+
26+
export interface LightingGridResult {
27+
width: number;
28+
height: number;
29+
cells: LightingCell[];
30+
}
31+
32+
function assertPositive(value: number, label: string): void {
33+
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value) || value <= 0) {
34+
throw new Error(`${label} must be a positive finite number.`);
35+
}
36+
}
37+
38+
function clamp(value: number, min: number, max: number): number {
39+
if (value < min) return min;
40+
if (value > max) return max;
41+
return value;
42+
}
43+
44+
function falloff(distance: number, radius: number, mode: FalloffMode): number {
45+
const t = clamp(distance / radius, 0, 1);
46+
switch (mode) {
47+
case 'linear':
48+
return 1 - t;
49+
case 'quadratic':
50+
return 1 - t * t;
51+
case 'smoothstep':
52+
return 1 - (t * t * (3 - 2 * t));
53+
default:
54+
return 1 - t;
55+
}
56+
}
57+
58+
function blendColor(base: [number, number, number], add: [number, number, number], weight: number): [number, number, number] {
59+
return [
60+
clamp(base[0] + add[0] * weight, 0, 1),
61+
clamp(base[1] + add[1] * weight, 0, 1),
62+
clamp(base[2] + add[2] * weight, 0, 1),
63+
];
64+
}
65+
66+
function defaultColor(): [number, number, number] {
67+
return [0, 0, 0];
68+
}
69+
70+
/**
71+
* Calculates a lighting grid for 2D tile maps with simple falloff.
72+
* Useful for: top-down games, roguelike rendering, and fog-of-war overlays.
73+
*/
74+
export function computeLightingGrid(options: LightingGridOptions): LightingGridResult {
75+
if (!Array.isArray(options.lights) || options.lights.length === 0) {
76+
throw new Error('lights must contain at least one point light.');
77+
}
78+
assertPositive(options.width, 'width');
79+
assertPositive(options.height, 'height');
80+
assertPositive(options.tileSize, 'tileSize');
81+
82+
const ambient = clamp(options.ambient ?? 0.1, 0, 1);
83+
const obstacles = options.obstacles ?? (() => false);
84+
85+
const cells: LightingCell[] = [];
86+
87+
const lights = options.lights as ReadonlyArray<PointLight>;
88+
89+
for (let y = 0; y < options.height; y += 1) {
90+
for (let x = 0; x < options.width; x += 1) {
91+
let light = ambient;
92+
let color: [number, number, number] = defaultColor();
93+
color = blendColor(color, [ambient, ambient, ambient], 1);
94+
95+
if (obstacles(x, y)) {
96+
cells.push({ light, color });
97+
continue;
98+
}
99+
100+
const worldX = (x + 0.5) * options.tileSize;
101+
const worldY = (y + 0.5) * options.tileSize;
102+
103+
for (const point of lights) {
104+
assertPositive(point.radius, 'light.radius');
105+
const dx = worldX - point.x;
106+
const dy = worldY - point.y;
107+
const distance = Math.sqrt(dx * dx + dy * dy);
108+
if (distance > point.radius) {
109+
continue;
110+
}
111+
const intensity = clamp(point.intensity ?? 1, 0, 10);
112+
const mode = point.falloff ?? 'smoothstep';
113+
const percent = falloff(distance, point.radius, mode) * intensity;
114+
light = clamp(light + percent, 0, 1);
115+
color = blendColor(color, point.color ?? [1, 0.95, 0.8], percent);
116+
}
117+
118+
cells.push({ light, color });
119+
}
120+
}
121+
122+
return {
123+
width: options.width,
124+
height: options.height,
125+
cells,
126+
};
127+
}

src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export const examples = {
104104
calculateDamage: 'examples/combat.ts',
105105
createCooldownController: 'examples/combat.ts',
106106
createQuestMachine: 'examples/quest.ts',
107+
computeLightingGrid: 'examples/lighting.ts',
107108
},
108109
ai: {
109110
seek: 'examples/steering.ts',
@@ -685,6 +686,21 @@ export type {
685686
QuestMachineSnapshot,
686687
} from './gameplay/questMachine.js';
687688

689+
/**
690+
* 2D lighting helpers for tile maps.
691+
*
692+
* Example file: examples/lighting.ts
693+
*/
694+
export { computeLightingGrid } from './gameplay/lighting.js';
695+
696+
export type {
697+
LightingGridOptions,
698+
LightingGridResult,
699+
LightingCell,
700+
PointLight,
701+
FalloffMode,
702+
} from './gameplay/lighting.js';
703+
688704

689705
// ============================================================================
690706
// 🔍 SEARCH & STRING UTILITIES

tests/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ describe('package entry point', () => {
115115
| 'calculateDamage'
116116
| 'createCooldownController'
117117
| 'createQuestMachine'
118+
| 'computeLightingGrid'
118119
>();
119120
});
120121
});

tests/lighting.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { computeLightingGrid } from '../src/index.js';
4+
5+
describe('computeLightingGrid', () => {
6+
it('applies falloff and ambient light', () => {
7+
const result = computeLightingGrid({
8+
width: 3,
9+
height: 3,
10+
tileSize: 1,
11+
ambient: 0.1,
12+
lights: [
13+
{ x: 1.5, y: 1.5, radius: 2, intensity: 1, falloff: 'linear', color: [1, 0.8, 0.6] },
14+
],
15+
});
16+
17+
expect(result.cells).toHaveLength(9);
18+
const center = result.cells[4];
19+
expect(center.light).toBeGreaterThan(0.9);
20+
const corner = result.cells[0];
21+
expect(corner.light).toBeGreaterThan(0.1);
22+
expect(corner.light).toBeLessThan(center.light);
23+
});
24+
25+
it('respects obstacles', () => {
26+
const result = computeLightingGrid({
27+
width: 3,
28+
height: 3,
29+
tileSize: 1,
30+
ambient: 0,
31+
lights: [{ x: 0.5, y: 0.5, radius: 3 }],
32+
obstacles: (x, y) => x === 1 && y === 0,
33+
});
34+
35+
const blocked = result.cells[1];
36+
expect(blocked.light).toBe(0);
37+
const next = result.cells[2];
38+
expect(next.light).toBeGreaterThan(0);
39+
});
40+
});

tests/questMachine.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe('createQuestMachine', () => {
66
it('advances states based on events and conditions', () => {
77
const context = { reputation: 0, reward: 0 };
88

9-
const machine = createQuestMachine<typeof context, { hasItem?: boolean }>({
9+
const machine = createQuestMachine<{ reputation: number; reward: number }, { hasItem?: boolean }>({
1010
context,
1111
initial: 'start',
1212
states: [

0 commit comments

Comments
 (0)