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