Ensure color_pos is always -1 when the lights go out, so behaviour is always consistent
[tabakrolletjie.git] / tabakrolletjie / rays.py
1 """ Light ray manipulation. Pew. Pew. Pew. Wommmm. """
2
3 import math
4
5 import pygame.rect
6
7 import pymunk
8 import pymunk.autogeometry
9 import pymunk.pygame_util
10
11 from .utils import debug_timer
12
13
14 def screen_rays(pos, bounding_radius):
15     """ An iterable that returns ordered rays from pos to the edge of the
16         screen, starting with the edge point (0, 0) and continuing clockwise
17         in pymunk coordinates.
18     """
19     r = int(bounding_radius)
20     left, right = int(pos.x) - r, int(pos.x) + r
21     bottom, top = int(pos.y) - r, int(pos.y) + r
22     step = 1
23     for y in range(bottom, top + 1, step):
24         yield pymunk.Vec2d(left, y)
25     for x in range(left, right + 1, step):
26         yield pymunk.Vec2d(x, top)
27     for y in range(top, bottom - 1, -step):
28         yield pymunk.Vec2d(right, y)
29     for x in range(right, left - 1, -step):
30         yield pymunk.Vec2d(x, bottom)
31
32
33 @debug_timer("rays.calculate_ray_polys")
34 def calculate_ray_polys(space, position, bounding_radius, light_filter):
35     """ Calculate a set of convex RayPolys that cover all the areas that light
36         can reach from the given position, taking into account the obstacles
37         present in the space.
38     """
39     position = pymunk.Vec2d(position)
40     vertices = [position]
41     start, end = None, None
42     ray_polys = []
43     for ray in screen_rays(position, bounding_radius):
44         info = space.segment_query_first(position, ray, 1, light_filter)
45         point = ray if info is None else info.point
46         vertices.append(point)
47         if len(vertices) == 2:
48             start = vertices[1]
49         elif len(vertices) > 3:
50             trial_poly = pymunk.Poly(None, vertices)
51             trial_poly.update(pymunk.Transform.identity())
52             query_prev = trial_poly.point_query(end)
53             query_pos = trial_poly.point_query(position)
54             if query_prev.distance < -0.01 or query_pos.distance < -0.01:
55                 ray_polys.append(RayPoly(position, vertices[:-1]))
56                 start = vertices[-1]
57                 vertices = [position, start]
58             else:
59                 vertices = trial_poly.get_vertices()
60         end = point
61     if len(vertices) > 2:
62         ray_polys.append(RayPoly(position, vertices))
63     return ray_polys
64
65
66 def to_pymunk_radians(deg):
67     """ Convert degrees in [0, 360] to radians in (-pi, pi].
68
69         Return None if degrees is None.
70     """
71     if deg is None:
72         return None
73     deg = deg * math.pi / 180.0
74     if deg > math.pi:
75         deg -= 2 * math.pi
76     return deg
77
78
79 class RayPolyManager(object):
80     def __init__(
81             self, body, position, ray_filter, radius_limits, direction,
82             spread, bounding_radius):
83         self._body = body  # light's body
84         self._position = pymunk.Vec2d(position)  # light's position
85         self._ray_filter = ray_filter  # light filter
86         self._rays = []  # list of RayPolys
87         self._direction = None  # normal vector for direction
88         self._start = None  # normal vector in direction of start angle limit
89         self._end = None  # normal vector in direction of end angle limit
90         self._set_angle_limits(direction, spread)
91         self._bounding_radius = None   # absolute maximum radius
92         if direction:
93             self.direction = direction  # Update direction
94         self._max_radius = None  # maximum radius in pixels
95         self._min_radius = None  # minimum radius in pixels
96         self._set_radius_limits(radius_limits)
97         self._set_bounding_radius(bounding_radius)
98         self._old_poly_cache = None  # last polys added to the space
99         self._poly_cache = None  # list of pymunk.Polys for rays
100         self._space = None  # space the rays form part of
101
102     def set_space(self, space):
103         self._space = space
104         self._rays = calculate_ray_polys(
105             self._space, self._position, self._bounding_radius,
106             self._ray_filter)
107         self._poly_cache = None
108
109     def update_shapes(self):
110         if self._old_poly_cache:
111             self._space.remove(*self._old_poly_cache)
112         new_polys = self._old_poly_cache = self.polys()
113         self._space.add(*new_polys)
114
115     @property
116     def position(self):
117         return self._position
118
119     @property
120     def max_radius(self):
121         return self._max_radius
122
123     @max_radius.setter
124     def max_radius(self, value):
125         self._max_radius = value or 0.0
126
127     @property
128     def min_radius(self):
129         return self._min_radius
130
131     @min_radius.setter
132     def min_radius(self, value):
133         self._min_radius = value or 0.0
134
135     def serialize(self):
136         """ Return the required information from the ray_manager """
137         if self._direction is None:
138             direction = None
139             spread = None
140         else:
141             direction = self._direction.angle_degrees
142             spread = math.degrees(self.spread)
143         return {
144             "radius_limits": (self._min_radius, self._max_radius),
145             "direction": direction,
146             "spread": spread,
147         }
148
149     def reaches(self, position):
150         distance = self.position.get_distance(position)
151         return (self._min_radius <= distance <= self._max_radius)
152
153     def _set_radius_limits(self, radius_limits):
154         if radius_limits is None or not radius_limits[0]:
155             self._min_radius = 0
156         else:
157             self._min_radius = radius_limits[0]
158         if radius_limits is None or not radius_limits[1]:
159             self._max_radius = 50.0
160         else:
161             self._max_radius = radius_limits[1]
162
163     def _set_bounding_radius(self, bounding_radius):
164         if bounding_radius is None:
165             bounding_radius = self._max_radius
166         self._bounding_radius = bounding_radius
167
168     def rotatable(self):
169         return self._direction is not None
170
171     @property
172     def direction(self):
173         if self._direction is None:
174             return 0
175         return self._direction.angle_degrees
176
177     @direction.setter
178     def direction(self, degrees):
179         spread = self._direction.get_angle_between(self._start)
180         self._direction.angle_degrees = degrees
181         self._start = self._direction.rotated(spread)
182         self._end = self._direction.rotated(-spread)
183         self._poly_cache = None
184
185     @property
186     def spread(self):
187         if not self._direction:
188             return 2 * math.pi
189         return math.fabs(self._start.get_angle_between(self._end))
190
191     def _set_angle_limits(self, direction, spread):
192         if direction is None or spread is None:
193             self._direction = None
194             self._start = None
195             self._end = None
196         else:
197             self._direction = pymunk.Vec2d(1, 0)
198             self._start = self._direction.rotated_degrees(-spread/2.)
199             self._end = self._direction.rotated_degrees(spread/2.)
200         self._poly_cache = None
201
202     def polys(self):
203         if self._poly_cache is None:
204             self._poly_cache = poly_cache = []
205             for rp in self._rays:
206                 poly = rp.poly(self._start, self._end)
207                 if poly:
208                     poly.body = self._body
209                     poly.filter = self._ray_filter
210                     poly_cache.append(poly)
211         return self._poly_cache
212
213     def pygame_position(self, surface):
214         return pymunk.pygame_util.to_pygame(self._position, surface)
215
216     def pygame_rect(self, surface):
217         half_width = self.max_radius
218         rect_width = half_width * 2
219         rect_x, rect_y = pymunk.pygame_util.to_pygame(self._position, surface)
220         dest_rect = pygame.rect.Rect(rect_x, rect_y, rect_width, rect_width)
221         dest_rect.move_ip(-half_width, -half_width)
222         return dest_rect
223
224     def pygame_polys(self, surface):
225         return [
226             [pymunk.pygame_util.to_pygame(v, surface)
227              for v in poly.get_vertices()]
228             for poly in self.polys()
229         ]
230
231
232 class RayPoly(object):
233     def __init__(self, position, vertices):
234         self.position = position  # pointy end of the conical polygon
235         self.vertices = vertices  # all vertices in the polygon
236
237     def _between(self, v, start, end):
238         if start < end:
239             return start <= v <= end
240         return (start <= v) or (v <= end)
241
242     def poly(self, start, end):
243         trial = pymunk.Poly(None, self.vertices)
244         trial.update(pymunk.Transform.identity())
245
246         if start is None or end is None:
247             return trial  # no limits
248
249         start_info = trial.segment_query(
250             self.position + 1250 * start, self.position + 0.1 * start, 0)
251         end_info = trial.segment_query(
252             self.position + 1250 * end, self.position + 0.1 * end, 0)
253
254         vertices = self.vertices[:]
255         vertices = [
256             v for v in vertices
257             if self._between((v - self.position).angle, start.angle, end.angle)
258         ]
259         if start_info.shape is not None:
260             vertices.append(start_info.point)
261         if end_info.shape is not None:
262             vertices.append(end_info.point)
263         vertices.append(self.position)
264
265         poly = pymunk.Poly(None, vertices)
266         if len(poly.get_vertices()) < 3:
267             return None
268         return poly