Source code for scripts.engine.world_objects.lighting

from __future__ import annotations

import math
import random
from typing import Dict, List, Optional, Tuple

import pygame
from pygame import BLEND_RGBA_ADD, BLEND_RGBA_MULT

__all__ = ["Light", "LightBox", "Wall"]


[docs]class Light: """ Holds the attributes for the light and offers some basic interface instructions. """
[docs] def __init__( self, pos: List[int], radius: int, light_img: pygame.Surface, colour: Tuple[int, int, int] = (255, 255, 255), alpha: int = 255, ): self._base_position: List[int] = pos # screen position self.position: List[int] = pos self._base_radius: int = radius # screen size self.radius: int = radius self._base_light_img: pygame.Surface = pygame.transform.scale(light_img, (radius * 2, radius * 2)) self._coloured_light_img: pygame.Surface = self._base_light_img.copy() self.light_img: pygame.Surface = self._base_light_img.copy() self.alpha: int = alpha self.colour: Tuple[int, int, int] = colour self.timer: int = 1 # timer for wave/pule of light self.flicker_timer: int = 1 # timer for jumping flicker self.variance = 0 # how much variance from radius due to flicker self.variance_size = int(self._base_radius / 30) self._calculate_light_img()
[docs] def update(self): base_radius = self._base_radius variance_size = self.variance_size # increment wave timer self.timer += 1 self.set_size(int((1 + math.sin(self.timer / 10)) + (base_radius + self.variance))) # decrement flicker timer self.flicker_timer -= 1 # update for flickering effect if self.flicker_timer < 0: # scale size self.variance = random.randint(-variance_size, variance_size) radius = base_radius + self.variance self.set_size(radius) # alpha variance alpha_variance = int(self.variance) self.set_alpha(self.alpha + alpha_variance) # set new timer self.flicker_timer = random.randint(30, 60)
def _calculate_light_img(self): """ Alter the original light image by all of the attributes given, e.g. alpha, colour, etc. """ self._coloured_light_img = mult_colour(set_mask_alpha(self._base_light_img, self.alpha), self.colour) self.light_img = self._coloured_light_img.copy()
[docs] def set_alpha(self, alpha: int): """ Set the alpha value of the light. Refreshes the mask and size. """ self.alpha = alpha self._coloured_light_img = set_mask_alpha(self._base_light_img, self.alpha) self.set_size(self.radius)
[docs] def set_colour(self, colour: Tuple[int, int, int], override_alpha: bool = False): """ Set the colour of the light. Refreshes the size. If `override_alpha` is set to `True`, the alpha setting is ignored when recalculating the light. This is better for performance. """ self.colour = colour if override_alpha: self._coloured_light_img = mult_colour(self._base_light_img, self.colour) else: self._calculate_light_img() self.set_size(self.radius)
[docs] def set_size(self, radius: int): """ Set the size of the light and rescale the image to match. """ self.radius = radius self.light_img = pygame.transform.scale(self._coloured_light_img, (radius * 2, radius * 2))
[docs]class Wall: """ Handles shadow casting within a Lightbox. """
[docs] def __init__( self, p1: List[int], p2: List[int], vertical: int, direction: int, colour: Tuple[int, int, int] = (255, 255, 255), ): self.p1 = p1 self.p2 = p2 # The vertical aspect of the wall that is used to determine direction for shadows (must be `1` or `0`). # Vertical refers to the direction of the face, not the direction of the wall, so if it's set to `1`, # the face is up/down and the line that makes up the wall is horizontal. self.vertical = vertical # The direction of the wall (inward/outward). This must be set to `-1` or `1`. The direction refers to the # axis the wall is on based on `Wall.vertical` with `-1` being associated with the negative direction on the # associated axis. self.direction = direction self.colour: Tuple[int, int, int] = colour # generate the rect for light_box collisions self.rect: pygame.Rect = self._create_rect()
[docs] def clone_move(self, offset: Tuple[int, int]) -> Wall: """ Create a duplicate Wall with an offset. """ return Wall( [self.p1[0] + offset[0], self.p1[1] + offset[1]], [self.p2[0] + offset[0], self.p2[1] + offset[1]], self.vertical, self.direction, self.colour, )
def _create_rect(self): """ Create a rect using the points in the wall """ r_p1 = [min(self.p1[0], self.p2[0]), min(self.p1[1], self.p2[1])] r_p2 = [max(self.p1[0], self.p2[0]), max(self.p1[1], self.p2[1])] # +1 in the x_size and y_size because straight walls have a width or height of 0 return pygame.Rect(r_p1[0], r_p1[1], r_p2[0] - r_p1[0] + 1, r_p2[1] - r_p1[1] + 1) def _check_cast(self, source) -> int: # will return 1 (or True) if the direction/position of the wall logically allows a shadow to be cast if (source[self.vertical] - self.p1[self.vertical]) * self.direction < 0: return 1 else: return 0 @staticmethod def _determine_cast_endpoint(source, point, vision_box): """ Determine the point on the vision_box's edge that is collinear to the light and the endpoint of the Wall. This must be called for each endpoint of the wall. """ difx = source[0] - point[0] dify = source[1] - point[1] try: slope = dify / difx # questionable, but looks alright except ZeroDivisionError: slope = 999999 if slope == 0: slope = 0.000001 # since the vision_box's edges are being treated as lines, there are technically 2 collinear points on the # vision box's edge # one must be a horizontal side and the other must be vertical since the 2 points must be on adjacent sides # determine which horizontal and which vertical sides of the vision box are used (top/bottom and left/right) cast_hside = 0 cast_vside = 0 if difx < 0: cast_hside = 1 if dify < 0: cast_vside = 1 # calculate the collinear points with quick mafs if cast_hside: hwall_p = [vision_box.right, slope * (vision_box.right - source[0]) + source[1]] else: hwall_p = [vision_box.left, slope * (vision_box.left - source[0]) + source[1]] if cast_vside: vwall_p = [(vision_box.bottom - source[1]) / slope + source[0], vision_box.bottom] else: vwall_p = [(vision_box.top - source[1]) / slope + source[0], vision_box.top] # calculate closer point out of the 2 collinear points and return side used if (abs(hwall_p[0] - source[0]) + abs(hwall_p[1] - source[1])) < ( abs(vwall_p[0] - source[0]) + abs(vwall_p[1] - source[1]) ): # horizontal sides use numbers 2 and 3 return hwall_p, cast_hside + 2 else: # vertical sides use numbers 0 and 1 return vwall_p, cast_vside def _get_intermediate_points(self, p1_side, p2_side, vision_box): """ Get the corner points for the polygon. If the casted shadow points for walls are on different vision_box sides, the corners between the points must be added. """ # the "sides" refer to the sides of the vision_box that the wall endpoints casted onto # 0 = top, 1 = bottom, 2 = left, 3 = right sides = [p1_side, p2_side] sides.sort() # return the appropriate sides based on the 2 sides # the first 4 are the cases where the 2 shadow points are on adjacent sides if sides == [0, 3]: return [vision_box.topright] elif sides == [1, 3]: return [vision_box.bottomright] elif sides == [1, 2]: return [vision_box.bottomleft] elif sides == [0, 2]: return [vision_box.topleft] # these 2 are for when the shadow points are on opposite sides (normally happens when the light source is # close to the wall) # the intermediate points depend on the direction the shadow was cast in this case (they could be on either # side without knowing the direction) elif sides == [0, 1]: if self.direction == -1: return [vision_box.topleft, vision_box.bottomleft] else: return [vision_box.topright, vision_box.bottomright] elif sides == [2, 3]: if self.direction == -1: return [vision_box.topleft, vision_box.topright] else: return [vision_box.bottomleft, vision_box.bottomright] # this happens if the sides are the same, which would mean the shadow doesn't cross sides and has no # intermediate points else: return []
[docs] def draw_shadow( self, surf: pygame.Surface, source: List[int], vision_box: pygame.Rect, colour: Tuple[int, int, int], offset: Optional[List[int]] = None, ): """ Draw a shadow, as cast by the light source. Primarily used internally by the `LightBox` class, but it's available for independent use if you want to do something crazy. In this context, `light_source` is point (`[x, y]`), not a `Light` object. The `vision_box` is just a `pygame.Rect` that specifies the visible area. The `color` is the color of the shadow. In normal use, the shadow is black and used to create a mask, but you can do some weird stuff by changing the color. """ # avoid mutable default if offset is None: offset = [0, 0] assert isinstance(offset, list) # check if a shadow needs to be casted if self._check_cast(source): # calculate the endpoints of the shadow when casted on the edge of the vision_box p1_shadow, p1_side = self._determine_cast_endpoint(source, self.p1, vision_box) p2_shadow, p2_side = self._determine_cast_endpoint(source, self.p2, vision_box) # calculate the intermediate points of the shadow (see the function for a more detailed description) intermediate_points = self._get_intermediate_points(p1_side, p2_side, vision_box) # arrange the points of the polygon points = [self.p1] + [p1_shadow] + intermediate_points + [p2_shadow] + [self.p2] # apply offset points = [[p[0] - offset[0], p[1] - offset[1]] for p in points] # draw the polygon pygame.draw.polygon(surf, colour, points)
[docs] def render(self, surf: pygame.Surface, offset: Optional[List[int]] = None): """ Render the line that makes up the wall. Mostly just useful for debugging. """ # avoid mutable default if offset is None: offset = [0, 0] assert isinstance(offset, list) pygame.draw.line( surf, self.colour, [self.p1[0] + offset[0], self.p1[1] + offset[1]], [self.p2[0] + offset[0], self.p2[1] + offset[1]], )
[docs]def box(pos: List[int], size: List[int]): """ Generate a box of Walls with all walls facing outwards. The pos is the top left of the box. This list of walls can be added to a LightBox using LightBox.add_walls. Useful for custom wall generation. """ walls = [] walls.append(Wall([pos[0], pos[1]], [pos[0] + size[0], pos[1]], 1, -1)) walls.append(Wall([pos[0], pos[1]], [pos[0], pos[1] + size[1]], 0, -1)) walls.append(Wall([pos[0] + size[0], pos[1]], [pos[0] + size[0], pos[1] + size[1]], 0, 1)) walls.append(Wall([pos[0], pos[1] + size[1]], [pos[0] + size[0], pos[1] + size[1]], 1, 1)) return walls
[docs]def point_str(point) -> str: """ Convert a point to a string """ # some string conversion functions (since looking up strings in a dict is pretty fast performance-wise) return str(point[0]) + ";" + str(point[1])
[docs]def line_str(line, point) -> str: """ Convert a line to a string """ return point_str(line[point]) + ";" + str(line[2][0]) + ";" + str(line[2][1])
[docs]def str_point(string: str): """ Convert string to point """ return [int(v) for v in string.split(";")[:2]]
[docs]def set_mask_alpha(surf: pygame.Surface, alpha: int) -> pygame.Surface: """ Set the alpha of the screen mask """ return mult_colour(surf, (alpha, alpha, alpha))
[docs]def mult_colour(surf: pygame.Surface, colour: Tuple[int, int, int]) -> pygame.Surface: """ Multiply the colour given on the provided surface. """ mult_surf = surf.copy() mult_surf.fill(colour) new_surf = surf.copy() new_surf.blit(mult_surf, (0, 0), special_flags=BLEND_RGBA_MULT) return new_surf
[docs]def get_chunk(point, chunk_size): return [point[0] // chunk_size, point[1] // chunk_size]
[docs]def generate_walls(light_box: LightBox, map_data: List[List[int]], tile_size: int) -> List[Wall]: """ Adds walls to the designated light box using a list of "air" (empty) tiles. Bordering sides will be joined together to reduce the wall count. The tile locations in the map_data should be the grid positions. The positions are then multiplied by the tile_size to get the pixel positions of the tiles along with the coordinates of the sides. The returned data is just a list of Wall objects that were added to the given LightBox. """ # looking up a string in a dict is significantly quicker than looking up in a list map_dict = {} lines = [] # generate a dict with all of the tiles for tile in map_data: map_dict[str(tile[0]) + ";" + str(tile[1])] = 1 # add all the walls by checking air tiles for bordering solid tiles (solid tiles are where there are no tiles in # the dict) for air_tile in map_data: # check all sides for each air tile if point_str([air_tile[0] + 1, air_tile[1]]) not in map_dict: # generate line in [p1, p2, [vertical, inside/outside]] format lines.append( [ [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size], [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size + tile_size], [0, -1], ] ) if point_str([air_tile[0] - 1, air_tile[1]]) not in map_dict: lines.append( [ [air_tile[0] * tile_size, air_tile[1] * tile_size], [air_tile[0] * tile_size, air_tile[1] * tile_size + tile_size], [0, 1], ] ) if point_str([air_tile[0], air_tile[1] + 1]) not in map_dict: lines.append( [ [air_tile[0] * tile_size, air_tile[1] * tile_size + tile_size], [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size + tile_size], [1, -1], ] ) if point_str([air_tile[0], air_tile[1] - 1]) not in map_dict: lines.append( [ [air_tile[0] * tile_size, air_tile[1] * tile_size], [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size], [1, 1], ] ) # reformat the data into a useful form for the geometry tricks later # this adds each endpoint to a dict as a key with the associated endpoint being in the list of associated values # (so 1 point can link to 2 bordering points where lines are connected) # it keys with respect to the vertical/horizontal aspect and the inward/outward aspect, so all lines that use the # same keys are part of a single joined line line_dict: Dict[str, List[str]] = {} for line in lines: for i in range(2): if line_str(line, i) in line_dict: line_dict[line_str(line, i)].append(line_str(line, 1 - i)) else: line_dict[line_str(line, i)] = [line_str(line, 1 - i)] final_walls = [] # keep track of the processed points so that those keys can be ignored (we add 4 points per line since each point # must be a key once and a value once) processed_points = [] for point in line_dict: # the length of the items in this dict are the number of connected points # so if there's only 1 connected point, that means it's the end of a line # we can then follow the line's points to calculate the single line based off the connections if point not in processed_points: # look for the end of the line and skip all the others (since anything else must be connected to an end # due to the respect to direction) if len(line_dict[point]) == 1: # add this point to the list to ignore processed_points.append(point) offset = 1 p1 = str_point(point) p2 = str_point(line_dict[point][0]) # calculate the direction based on the 2 points direction = [(p2[0] - p1[0]) // tile_size, (p2[1] - p1[1]) // tile_size] # loop through the connected points until the other end is found while 1: # generate the string for the next point target_pos = ( str(p1[0] + direction[0] * offset * tile_size) + ";" + str(p1[1] + direction[1] * offset * tile_size) + ";" + point.split(";")[2] + ";" + point.split(";")[3] ) # when the connected point only links to 1 point, you've found the other end of the line processed_points.append(target_pos) if len(line_dict[target_pos]) == 1: break offset += 1 # append to the walls list based on the last point found and the starting point final_walls.append([p1, str_point(target_pos), int(point.split(";")[2]), int(point.split(";")[3])]) # correct overshot edges (must be done after grouping for proper results) and generate Wall objects for wall in final_walls: grid_pos_x = wall[0][0] grid_pos_y = wall[0][1] # get tile location of wall tile_x = int(grid_pos_x // tile_size) tile_y = int(grid_pos_y // tile_size) # check for relevant bordering tiles to determine if it's okay to shorten the wall if not wall[2]: if wall[3] == 1: if [tile_x, tile_y + 1] in map_data: wall[1][1] -= 1 else: if [tile_x - 1, tile_y + 1] in map_data: wall[1][1] -= 1 # move right-facing wall inward if wall[3] == 1: wall[0][0] -= 1 wall[1][0] -= 1 else: if wall[3] == 1: if [tile_x + 1, tile_y] in map_data: wall[1][0] -= 1 else: if [tile_x + 1, tile_y - 1] in map_data: wall[1][0] -= 1 # move downward-facing wall inward if wall[3] == 1: wall[0][1] -= 1 wall[1][1] -= 1 # generate Wall objects _final_walls = [Wall(*wall) for wall in final_walls] # apply walls light_box.add_walls(_final_walls) # return the list just in case it's needed for something return _final_walls