2D Lighting

Coloured point lights with shadow-casting occluders.

▶ Run in browser

Tags: 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())