2D Lighting¶
2D Lighting Demo — Coloured point lights with shadow-casting occluders.
Run: uv run python packages/graphics/examples/2d_light.py
Controls:
Mouse moves the white “cursor” light
WASD moves the camera/scene offset
1/2/3 toggles individual lights on/off
S key toggles shadows on/off
ESC quits
Source Code¶
1"""
22D Lighting Demo — Coloured point lights with shadow-casting occluders.
3
4Run: uv run python packages/graphics/examples/2d_light.py
5
6Controls:
7 - Mouse moves the white "cursor" light
8 - WASD moves the camera/scene offset
9 - 1/2/3 toggles individual lights on/off
10 - S key toggles shadows on/off
11 - ESC quits
12"""
13
14
15import math
16import random
17
18from simvx.core import (
19 Input,
20 InputMap,
21 Key,
22 LightOccluder2D,
23 Node2D,
24 PointLight2D,
25 Property,
26 Vec2,
27)
28from simvx.graphics import App
29
30WIDTH, HEIGHT = 1024, 768
31
32# Box shapes for occluders (walls / obstacles)
33BOX_SMALL = [(-30, -30), (30, -30), (30, 30), (-30, 30)]
34BOX_WIDE = [(-80, -15), (80, -15), (80, 15), (-80, 15)]
35TRIANGLE = [(-40, 30), (0, -40), (40, 30)]
36
37
38class MouseLight(PointLight2D):
39 """A light that follows the mouse cursor."""
40
41 def __init__(self, **kwargs):
42 super().__init__(**kwargs)
43 self.colour = (1.0, 1.0, 1.0)
44 self.energy = 1.2
45 self.range = 250.0
46 self.falloff = 1.5
47
48 def process(self, dt: float):
49 mx, my = Input.mouse_position
50 self.position = Vec2(mx, my)
51
52
53class OrbitingLight(PointLight2D):
54 """A light that orbits around a center point."""
55
56 orbit_radius = Property(150.0)
57 orbit_speed = Property(1.0)
58
59 def __init__(self, center: Vec2 = None, **kwargs):
60 super().__init__(**kwargs)
61 self._center = center or Vec2(WIDTH / 2, HEIGHT / 2)
62 self._angle = random.uniform(0, math.tau)
63
64 def process(self, dt: float):
65 self._angle += self.orbit_speed * dt
66 self.position = Vec2(
67 self._center.x + math.cos(self._angle) * self.orbit_radius,
68 self._center.y + math.sin(self._angle) * self.orbit_radius,
69 )
70
71
72class LightingDemo(Node2D):
73 """Main scene demonstrating 2D lighting with shadows."""
74
75 shadows_on = Property(True)
76
77 def __init__(self, **kwargs):
78 super().__init__(name="LightingDemo", **kwargs)
79 self._lights: list[PointLight2D] = []
80
81 def ready(self):
82 InputMap.add_action("toggle_1", [Key.KEY_1])
83 InputMap.add_action("toggle_2", [Key.KEY_2])
84 InputMap.add_action("toggle_3", [Key.KEY_3])
85 InputMap.add_action("toggle_shadows", [Key.S])
86 InputMap.add_action("quit", [Key.ESCAPE])
87
88 # --- Lights ---
89
90 # Red orbiting light
91 red = self.add_child(
92 OrbitingLight(
93 name="RedLight",
94 center=Vec2(WIDTH * 0.3, HEIGHT * 0.4),
95 )
96 )
97 red.colour = (1.0, 0.2, 0.1)
98 red.energy = 1.8
99 red.range = 300.0
100 red.falloff = 1.2
101 red.orbit_radius = 120.0
102 red.orbit_speed = 0.8
103 red.shadow_enabled = True
104 self._lights.append(red)
105
106 # Green orbiting light
107 green = self.add_child(
108 OrbitingLight(
109 name="GreenLight",
110 center=Vec2(WIDTH * 0.7, HEIGHT * 0.4),
111 )
112 )
113 green.colour = (0.1, 1.0, 0.3)
114 green.energy = 1.5
115 green.range = 280.0
116 green.falloff = 1.5
117 green.orbit_radius = 100.0
118 green.orbit_speed = -1.2
119 green.shadow_enabled = True
120 self._lights.append(green)
121
122 # Blue pulsing light (stationary)
123 blue = self.add_child(
124 PointLight2D(
125 name="BlueLight",
126 position=Vec2(WIDTH * 0.5, HEIGHT * 0.7),
127 )
128 )
129 blue.colour = (0.2, 0.4, 1.0)
130 blue.energy = 2.0
131 blue.range = 350.0
132 blue.falloff = 0.8
133 blue.shadow_enabled = True
134 self._lights.append(blue)
135
136 # Mouse-following white light
137 mouse = self.add_child(MouseLight(name="MouseLight"))
138 mouse.shadow_enabled = True
139 self._lights.append(mouse)
140
141 # --- Occluders (walls / obstacles) ---
142
143 # Center box
144 o1 = self.add_child(
145 LightOccluder2D(
146 name="CenterBox",
147 position=Vec2(WIDTH * 0.5, HEIGHT * 0.45),
148 )
149 )
150 o1.polygon = BOX_SMALL
151
152 # Left wall
153 o2 = self.add_child(
154 LightOccluder2D(
155 name="LeftWall",
156 position=Vec2(WIDTH * 0.25, HEIGHT * 0.5),
157 rotation=math.radians(30),
158 )
159 )
160 o2.polygon = BOX_WIDE
161
162 # Right triangle
163 o3 = self.add_child(
164 LightOccluder2D(
165 name="RightTriangle",
166 position=Vec2(WIDTH * 0.75, HEIGHT * 0.6),
167 )
168 )
169 o3.polygon = TRIANGLE
170
171 # Top barrier
172 o4 = self.add_child(
173 LightOccluder2D(
174 name="TopBarrier",
175 position=Vec2(WIDTH * 0.5, HEIGHT * 0.2),
176 )
177 )
178 o4.polygon = BOX_WIDE
179
180 # Scattered small boxes
181 for i in range(4):
182 x = WIDTH * (0.2 + 0.2 * i)
183 y = HEIGHT * 0.8
184 ob = self.add_child(
185 LightOccluder2D(
186 name=f"SmallBox{i}",
187 position=Vec2(x, y),
188 rotation=math.radians(random.uniform(-20, 20)),
189 )
190 )
191 ob.polygon = [(-20, -20), (20, -20), (20, 20), (-20, 20)]
192
193 def process(self, dt: float):
194 self._elapsed = getattr(self, "_elapsed", 0.0) + dt
195
196 # Toggle lights with number keys
197 if Input.is_action_just_pressed("toggle_1") and len(self._lights) > 0:
198 self._lights[0].enabled = not self._lights[0].enabled
199 if Input.is_action_just_pressed("toggle_2") and len(self._lights) > 1:
200 self._lights[1].enabled = not self._lights[1].enabled
201 if Input.is_action_just_pressed("toggle_3") and len(self._lights) > 2:
202 self._lights[2].enabled = not self._lights[2].enabled
203
204 # Toggle shadows
205 if Input.is_action_just_pressed("toggle_shadows"):
206 self.shadows_on = not self.shadows_on
207 for light in self._lights:
208 light.shadow_enabled = self.shadows_on
209
210 if Input.is_action_just_pressed("quit"):
211 self.app.quit()
212
213 # Pulse the blue light
214 if len(self._lights) > 2:
215 blue = self._lights[2]
216 blue.energy = 1.5 + 0.5 * math.sin(self._elapsed * 3.0)
217
218 def draw(self, renderer):
219 # Draw the scene background — dark floor
220 renderer.draw_rect((0, 0), (WIDTH, HEIGHT), colour=(0.06, 0.06, 0.1), filled=True)
221
222 # Draw occluder outlines for visibility
223 for child in self.children:
224 if isinstance(child, LightOccluder2D) and child.polygon:
225 verts = child.global_polygon
226 if len(verts) >= 2:
227 pts_px = [(int(v[0]), int(v[1])) for v in verts]
228 renderer.draw_lines(pts_px, closed=True, colour=(0.24, 0.24, 0.31))
229
230 # Draw light position indicators
231 for light in self._lights:
232 if not light.enabled:
233 continue
234 lx, ly = int(light.world_position.x), int(light.world_position.y)
235 renderer.draw_circle((lx, ly), 5, colour=light.colour, segments=12, filled=True)
236
237 # HUD
238 renderer.draw_text("2D LIGHTING DEMO", (10, 10), scale=3, colour=(0.78, 0.78, 0.78))
239 renderer.draw_text("Mouse = white light | 1/2/3 = toggle lights", (10, 60), scale=2, colour=(0.59, 0.59, 0.59))
240 shadow_text = "ON" if self.shadows_on else "OFF"
241 renderer.draw_text(f"S = shadows [{shadow_text}] | ESC = quit", (10, 90), scale=2, colour=(0.59, 0.59, 0.59))
242
243 # Light status
244 for i, light in enumerate(self._lights[:3]):
245 status = "ON" if light.enabled else "OFF"
246 r, g, b = light.colour
247 renderer.draw_text(f"Light {i+1}: {status}", (10, HEIGHT - 100 + i * 30), scale=2, colour=(r * 0.78, g * 0.78, b * 0.78))
248
249
250if __name__ == "__main__":
251 App("2D Lighting Demo", WIDTH, HEIGHT).run(LightingDemo())