├── LICENSE.md ├── README.md ├── example ├── __pycache__ │ └── lighting.cpython-37.pyc ├── example.py ├── light.png ├── lighting.py └── map.txt └── lighting.py /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 DaFluffyPotato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pygame-shadows 2 | a simple shadow library for Pygame 3 | 4 | ![](https://media.discordapp.net/attachments/758065135423062027/772293910440443924/gif_12.gif) 5 | 6 | # Lighting Documentation 7 | This is the documentation for this Pygame lighting module. Please note that this module requires Pygame 2. Some functions and attributes have been omitted as they are primary used internally. Please see the example script for the example usage. Many of these functions will not be needed in the average use case. 8 | 9 | ## Classes 10 | 11 | ### LightBox([size_x : int, size_y : int, blit_flags=BLEND_RGBA_ADD]) 12 | This is the main class of this module. It stores all of the lights and walls. At a top level, it represents the viewable area for the light. The dimensions provided upon initialization will usually be the dimensions of your display. The `blit_flags` argument determines the blitting flags used in `LightBox.render()`. By default, it uses the adding flags which adds light values. However, other flags such as `BLEND_RGBA_MULT` can be used. `BLEND_RGBA_MULT` multiplies the destination surface by the lighting surface which results in darkening the areas that aren't lit up instead of brightening the areas that are. 13 | ##### LightBox.add_light(light : Light) -> light_id 14 | This function adds a `Light` object (provided as an argument) to the light box and returns a `light_id` that can be used to access the light later. 15 | ##### LightBox.get_light(light_id : str) -> Light 16 | This function returns the `Light` object with the associated ID from the list of lights in the light box. The position, radius, and image for the light can be modified and it'll update within the light box since the light box points to that object. If you get a `KeyError`, the ID you searched for doesn't exist. 17 | ##### LightBox.del_light(light_id : str) -> Light 18 | This function deletes a `Light` object from the light box based on the ID. 19 | ##### LightBox.add_walls(walls : list) -> None 20 | This function adds a list of `Wall` objects to the light box. (To be clear, `walls` in the parameters should look something like `[Wall(), Wall(), Wall()]`.) This function is used for manual wall additions. Typically, terrain is loaded using the `generate_walls()` function. 21 | ##### LightBox.add_dynamic_walls(walls : list) -> wall_group_id 22 | This function adds a group of dynamic walls that can be modified or deleted through the ID given. It works similarly to `LightBox.add_walls()`. It just uses a different system in the background that allows modification but performs worse. 23 | ##### LightBox.update_dynamic_walls(group_id : str, walls : list) -> None 24 | This function updates the group of walls with the given ID. While this is useful for overwriting the walls and takes walls in the same format as `LightBox.add_walls()`. You can actually modify the existing walls that you originally passed to `LightBox.add_dynamic_walls()` for better performance by just modifying the original `Wall` objects since objects use pointers. 25 | ##### LightBox.del_dynamic_walls(group_id : str) -> None 26 | This function deletes a group of walls based on the ID given. 27 | ##### LightBox.clear_walls() -> None 28 | This function deletes all of the `Wall` objects associated with the light box. 29 | ##### LightBox.render(target_surface : pygame.Surface, offset=[0, 0]) -> visible_walls 30 | This function is the primary lighting rendering function. The `target_surface` is the `pygame.Surface` that will have the lighting rendered onto it. If you render onto a black surface, you get the internal lighting mask. This technique can be useful for static lighting. However, normally you'll be rendering your lighting onto your main display surface. The `offset` is used to specify the terrain offset (aka camera offset or scroll) of the game relative to the viewed area. 31 | ##### LightBox.vision_box_r : pygame.Rect 32 | This is the `pygame.Rect()` that represents the light box. The position of this rect should be `[0, 0]` as the top left of a window's coordinates are also `[0, 0]`. Terrain offset is applied elsewhere. You can adjust the size of this rect to adjust the size of your visible area using the `pygame.Rect.width` and the `pygame.Rect.height` attributes. 33 | ##### LightBox.walls : list 34 | This is the list of walls contained within the light box. When used on a simple level, this attribute probably shouldn't be touched. Use `LightBox.add_walls()` and `LightBox.clear_walls()` instead. This is just a list of instances of the `Wall` class. 35 | ##### LightBox.lights : dict 36 | This is a dictionary containing all of the `Light` objects. Since lights can be moved, it's setup to be accessed with light IDs (the keys of the dictionary). This attribute likely won't need to be touched. Use `LightBox.add_light()`, `LightBox.del_light()`, and `LightBox.get_light()` instead. 37 | 38 | ### Wall(p1 : list, p2 : list, vertical : int, direction : int, color=(255, 255, 255)) 39 | The class for the walls that cast shadows. The shadow calculation is done in this class. Refer to the associated attributes for more info on the parameters (these will be set upon initialization based on the parameters). Most of the time, `Wall` objects will be generated by the `generate_walls()` function. Please note that a few functions aren't listed here as they likely aren't useful on their own. More info is in the source code if you *really* want to look. 40 | ##### Wall.draw_shadow(target_surf : pygame.Surface, light_source : list, vision_box : pygame.Rect, color : tuple) -> None 41 | The function for drawing the shadow for the wall onto the `target_surf` (a `pygame.Surface`). This function is 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. 42 | ##### Wall.render(target_surf : pygame.Surface, offset=[0, 0]) -> None 43 | A function for rendering the line that makes up the `Wall` onto the `target_surf` (`pygame.Surface`) with the specified `offset`. This module is primarily for rendering lighting though, so wall rendering is mostly just a debug tool. 44 | ##### Wall.p1 : list 45 | The first endpoint of the wall. Stored in an `[x, y]` format. 46 | ##### Wall.p2 : list 47 | The second endpoint of the wall. 48 | ##### Wall.vertical : int 49 | 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. 50 | ##### Wall.direction : int 51 | 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. 52 | ##### Wall.color : tuple 53 | A tuple in the form of `(red, green, blue)` with colors from in the range of `0-255`. This color is just used for rendering the wall if you choose to do so, but in most cases, the wall is invisible and is just used to calculate shadows. 54 | 55 | ### Light([x_pos : int, y_pos : int], radius : int, light_image : pygame.Surface) 56 | The base object for lights for use in the `LightBox`. You will need to create some of these for use with the `LightBox.add_light()` function. See the associated attributes for more info on what the parameters are used for. The position list goes into `Light.position`. Please note that the `LightBox.get_light()` function returns `Light` objects so that you can manipulate existing lights using the attributes of the object. 57 | ##### Light.set_alpha(alpha : int) -> None 58 | Sets the alpha of the `Light`. This is used to make a light dim. 59 | ##### Light.set_color((red : int, green : int, blue : int), override_alpha=False) -> None 60 | Sets the color of the `Light`. If `override_alpha` is set to `True`, the alpha setting is ignored when recalculating the light. This is better for performance. 61 | ##### Light.set_size(radius : int) -> None 62 | Set the radius of the `Light`. Please note that this must be an integer. 63 | ##### Light.position : list 64 | The position of the light which is stored as `[x_pos, y_pos]`. You can modify this attribute to move the `Light`. 65 | ##### Light.radius : int 66 | The radius of the light. If you modify this value, the `Light.light_img` will need to be updated to the appropriate size as well. 67 | ##### Light.light_img : pygame.Surface 68 | The image associated with the light. It's resized to the appropriate scale upon initialization of the `Light`, but must be updated any time the `Light.radius` is changed. 69 | 70 | ## Standalone Functions 71 | 72 | ### generate_walls(light_box : LightBox, map_data : list, tile_size : int) -> walls_generated 73 | This is the function for adding walls to the terrain based on a tile map (bordering sides will be joined to reduce the wall count). The `light_box` parameter is the `LightBox` object that the generated walls will automatically be added to. The `map_data` list is a list of air tiles. The list contains a bunch of tiles which are represented as lists in the form of `[x, y]`, so the `map_data` could look like `[[0, 0], [1, 0], [0, 2], [3, 9]]`. The tile locations 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`. 74 | 75 | ### box([x_pos : int, y_pos: int], [size_x : int, size_y : int]) -> walls_generated 76 | This is a function for generating a list of `Wall` objects in the shape of a box with all walls facing outwards. The `x_pos` and `y_pos` are the top left of the box. This list of walls can be added to a `LightBox` using `LightBox.add_walls()`. 77 | 78 | # Credits 79 | 80 | Original Lighting Module - DaFluffyPotato 81 | 82 | Code Cleanup and PEP-ification - [@Snayff](https://github.com/Snayff) 83 | -------------------------------------------------------------------------------- /example/__pycache__/lighting.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DaFluffyPotato/pygame-shadows/9128e6f480e772880ab8d49a17ee8ace4c32037e/example/__pycache__/lighting.cpython-37.pyc -------------------------------------------------------------------------------- /example/example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.4 2 | # Setup Python ----------------------------------------------- # 3 | import pygame, sys, math 4 | 5 | import lighting 6 | 7 | # Setup pygame/window ---------------------------------------- # 8 | mainClock = pygame.time.Clock() 9 | from pygame.locals import * 10 | pygame.init() 11 | pygame.display.set_caption('lighting example') 12 | screen = pygame.display.set_mode((500, 500), 0, 32) 13 | 14 | # load a map from a text file into the appropraite format for the lighting system 15 | def load_map(map_id): 16 | f = open(map_id + '.txt', 'r') 17 | dat = f.read() 18 | f.close() 19 | tile_list = [] 20 | y = 0 21 | for row in dat.split('\n'): 22 | x = 0 23 | for col in row: 24 | if col == '0': 25 | tile_list.append([x, y]) 26 | x += 1 27 | y += 1 28 | return tile_list 29 | 30 | # load the light image 31 | light_img = pygame.image.load('light.png').convert() 32 | 33 | # define the light box 34 | light_box = lighting.LightBox(screen.get_size()) 35 | 36 | # create the lights (mouse_lights will contain a list of the light IDs) 37 | # just setting a dummy position of [0, 0]. this will be moved later 38 | mouse_lights = [light_box.add_light(lighting.Light([0, 0], 80, light_img, (100, 50, 255), 255)) for i in range(9)] 39 | # light positions relative to the mouse 40 | mouse_light_offsets = [[(i % 3 - 1) * 30, (i // 3 - 1) * 30] for i in range(9)] 41 | 42 | map_data = load_map('map') 43 | lighting.generate_walls(light_box, map_data, 25) 44 | print(len(light_box.walls)) 45 | 46 | moving_box_id = light_box.add_dynamic_walls(lighting.box([1200, 100], [20, 20])) 47 | 48 | light_color = [100, 50, 255] 49 | 50 | offset = [0, 0] 51 | up = False 52 | down = False 53 | right = False 54 | left = False 55 | 56 | timer = 0 57 | 58 | # Loop ------------------------------------------------------- # 59 | while True: 60 | 61 | # Background --------------------------------------------- # 62 | screen.fill((0, 0, 0)) 63 | 64 | # Misc Processing ---------------------------------------- # 65 | 66 | timer += 1 67 | 68 | if right: 69 | offset[0] += 2 70 | if left: 71 | offset[0] -= 2 72 | if up: 73 | offset[1] -= 2 74 | if down: 75 | offset[1] += 2 76 | 77 | light_box.update_dynamic_walls(moving_box_id, lighting.box([1200 + math.sin(timer / 100) * 50, 100 + math.sin(timer / 72) * 100], [(1 + math.sin(timer / 60)) * 50, (1 + math.sin(timer / 65)) * 50])) 78 | 79 | # calculate new light color 80 | light_color = [100 + math.sin(timer / 10) * 100, 50 + math.sin(timer / 25) * 50, 200 + math.sin(timer / 15) * 55] 81 | # set alpha to 10% 82 | light_color = [v * 0.2 for v in light_color] 83 | 84 | # Update Lights ------------------------------------------ # 85 | mouse_light_offsets = [[(i % 3 - 1) * math.sin(timer / 40) * 60, (i // 3 - 1) * math.sin(timer / 40) * 60] for i in range(9)] 86 | mx, my = pygame.mouse.get_pos() 87 | for i, light in enumerate(mouse_lights): 88 | # True argument overrides light alpha for faster updates 89 | light_box.get_light(light).set_color(light_color, True) 90 | 91 | light_box.get_light(light).position = [offset[0] + mx + mouse_light_offsets[i][0], offset[1] + my + mouse_light_offsets[i][1]] 92 | light_box.get_light(light).set_size(int((1 + math.sin(timer / 15)) * 40 + 50)) 93 | 94 | # Render ------------------------------------------------- # 95 | # lighting 96 | visible_walls = light_box.render(screen, offset) 97 | 98 | # wall lines 99 | for wall in visible_walls: 100 | wall.render(screen) 101 | 102 | # dots for light 103 | for m in mouse_light_offsets: 104 | pygame.draw.circle(screen, (255, 0, 0), (mx + m[0], my + m[1]), 3) 105 | 106 | # Buttons ------------------------------------------------ # 107 | for event in pygame.event.get(): 108 | if event.type == QUIT: 109 | pygame.quit() 110 | sys.exit() 111 | if event.type == KEYDOWN: 112 | if event.key == K_ESCAPE: 113 | pygame.quit() 114 | sys.exit() 115 | if event.key == K_d: 116 | right = True 117 | if event.key == K_a: 118 | left = True 119 | if event.key == K_s: 120 | down = True 121 | if event.key == K_w: 122 | up = True 123 | if event.key == K_e: 124 | print('fps', int(mainClock.get_fps())) 125 | if event.key == K_q: 126 | print('visible walls:', len(visible_walls)) 127 | if event.type == KEYUP: 128 | if event.key == K_d: 129 | right = False 130 | if event.key == K_a: 131 | left = False 132 | if event.key == K_s: 133 | down = False 134 | if event.key == K_w: 135 | up = False 136 | 137 | # Update ------------------------------------------------- # 138 | pygame.display.update() 139 | mainClock.tick(60) 140 | 141 | -------------------------------------------------------------------------------- /example/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DaFluffyPotato/pygame-shadows/9128e6f480e772880ab8d49a17ee8ace4c32037e/example/light.png -------------------------------------------------------------------------------- /example/lighting.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import random 5 | from typing import Dict, List, Optional, Tuple 6 | 7 | import pygame 8 | from pygame import BLEND_RGBA_ADD, BLEND_RGBA_MULT 9 | 10 | __all__ = ["Light", "LightBox", "Wall"] 11 | 12 | 13 | class Light: 14 | """ 15 | Holds the attributes for the light and offers some basic interface instructions. 16 | """ 17 | 18 | def __init__( 19 | self, 20 | pos: List[int], 21 | radius: int, 22 | light_img: pygame.Surface, 23 | color: Tuple[int, int, int] = (255, 255, 255), 24 | alpha: int = 255, 25 | ): 26 | self._base_position: List[int] = pos # screen position 27 | self.position: List[int] = pos 28 | self._base_radius: int = radius # screen size 29 | self.radius: int = radius 30 | self._base_light_img: pygame.Surface = pygame.transform.scale(light_img, (radius * 2, radius * 2)) 31 | self._colored_light_img: pygame.Surface = self._base_light_img.copy() 32 | self.light_img: pygame.Surface = self._base_light_img.copy() 33 | self.alpha: int = alpha 34 | self.color: Tuple[int, int, int] = color 35 | self.timer: int = 1 # timer for wave/pule of light 36 | self.flicker_timer: int = 1 # timer for jumping flicker 37 | self.variance = 0 # how much variance from radius due to flicker 38 | self.variance_size = int(self._base_radius / 30) 39 | 40 | self._calculate_light_img() 41 | 42 | def update(self): 43 | base_radius = self._base_radius 44 | variance_size = self.variance_size 45 | 46 | # increment wave timer 47 | self.timer += 1 48 | self.set_size(int((1 + math.sin(self.timer / 10)) + (base_radius + self.variance))) 49 | 50 | # decrement flicker timer 51 | self.flicker_timer -= 1 52 | 53 | # update for flickering effect 54 | if self.flicker_timer < 0: 55 | # scale size 56 | self.variance = random.randint(-variance_size, variance_size) 57 | radius = base_radius + self.variance 58 | self.set_size(radius) 59 | 60 | # alpha variance 61 | alpha_variance = int(self.variance) 62 | self.set_alpha(max(0, min(255, self.alpha + alpha_variance))) 63 | 64 | # set new timer 65 | self.flicker_timer = random.randint(30, 60) 66 | 67 | def _calculate_light_img(self): 68 | """ 69 | Alter the original light image by all of the attributes given, e.g. alpha, color, etc. 70 | """ 71 | self._colored_light_img = mult_color(set_mask_alpha(self._base_light_img, self.alpha), self.color) 72 | self.light_img = self._colored_light_img.copy() 73 | 74 | def set_alpha(self, alpha: int): 75 | """ 76 | Set the alpha value of the light. Refreshes the mask and size. 77 | """ 78 | self.alpha = alpha 79 | self._colored_light_img = set_mask_alpha(self._base_light_img, self.alpha) 80 | self.set_size(self.radius) 81 | 82 | def set_color(self, color: Tuple[int, int, int], override_alpha: bool = False): 83 | """ 84 | Set the color of the light. Refreshes the size. If `override_alpha` is set to `True`, the alpha setting is 85 | ignored when recalculating the light. This is better for performance. 86 | """ 87 | self.color = color 88 | if override_alpha: 89 | self._colored_light_img = mult_color(self._base_light_img, self.color) 90 | else: 91 | self._calculate_light_img() 92 | self.set_size(self.radius) 93 | 94 | def set_size(self, radius: int): 95 | """ 96 | Set the size of the light and rescale the image to match. 97 | """ 98 | self.radius = radius 99 | self.light_img = pygame.transform.scale(self._colored_light_img, (radius * 2, radius * 2)) 100 | 101 | 102 | class LightBox: 103 | """ 104 | Handles the processing of Lights. 105 | The name "LightBox" comes from the idea that the lighting is only rendered within the "box" of the display. 106 | 107 | The dimensions provided upon initialization will usually be the dimensions of your display. The `blit_flags` 108 | argument determines the blitting flags used in `LightBox.render()`. By default, it uses the adding flags which 109 | adds light values. However, other flags such as `BLEND_RGBA_MULT` can be used. `BLEND_RGBA_MULT` multiplies the 110 | destination surface by the lighting surface which results in darkening the areas that aren't lit up instead of 111 | brightening the areas that are. 112 | """ 113 | 114 | def __init__(self, size: Tuple[int, int], blit_flags: int = BLEND_RGBA_ADD): 115 | self.vision_box_r: pygame.Rect = pygame.Rect(0, 0, size[0], size[1]) 116 | # The position of this rect should be `[0, 0]` as the top left of a window's coordinates are also `[0, 117 | # 0]`. Terrain offset is applied elsewhere. We can adjust the size of this rect to adjust the size of your 118 | # visible area using the `pygame.Rect.width` and the `pygame.Rect.height` attributes. 119 | 120 | self.walls: List[Wall] = [] 121 | 122 | # dict for storing walls by chunk 123 | self.chunk_walls: Dict = {} 124 | # size of chunks (tweak for better performance) 125 | self.chunk_size: int = 80 126 | # the amount of extra chunks in each direction the game should process (necessary for large lights outside 127 | # the light box) 128 | self.chunk_overshoot: int = 1 129 | 130 | # dict for storing dynamic walls 131 | self.dynamic_walls: Dict = {} 132 | # keeps track of the IDs for dynamic walls so they have different IDs 133 | self.dynamic_wall_id: int = 0 134 | 135 | self.lights: Dict[str, Light] = {} 136 | 137 | # keeps track of IDs for lights so that each new light has a different ID 138 | # converted to str when added to dict 139 | self.light_id: int = 0 140 | 141 | self.blit_flags: int = blit_flags 142 | 143 | def add_walls(self, walls: List[Wall]): 144 | """ 145 | Add walls to the lightbox. 146 | This is used for manual wall additions, however, typically, walls are loaded using generate_walls. 147 | """ 148 | self.walls += walls 149 | 150 | # split walls into all the chunks they cross 151 | # this creates duplicates, but the name keys can be put into a dict to efficiently remove duplicates 152 | for wall in walls: 153 | wall_str = point_str(wall.p1) + ";" + point_str(wall.p2) + ";" + str(wall.direction) 154 | p1_chunk = get_chunk(wall.p1, self.chunk_size) 155 | p2_chunk = get_chunk(wall.p2, self.chunk_size) 156 | chunk_list = [] 157 | if p1_chunk != p2_chunk: 158 | if abs(p1_chunk[0] - p2_chunk[0]) > 0: 159 | for i in range(max(p1_chunk[0], p2_chunk[0]) - min(p1_chunk[0], p2_chunk[0]) + 1): 160 | chunk_list.append([min(p1_chunk[0], p2_chunk[0]) + i, p1_chunk[1]]) 161 | elif abs(p1_chunk[1] - p2_chunk[1]) > 0: 162 | for i in range(max(p1_chunk[1], p2_chunk[1]) - min(p1_chunk[1], p2_chunk[1]) + 1): 163 | chunk_list.append([p1_chunk[0], min(p1_chunk[1], p2_chunk[1]) + i]) 164 | else: 165 | chunk_list = [p1_chunk, p2_chunk] 166 | for chunk in chunk_list: 167 | chunk_str = point_str(chunk) 168 | if chunk_str not in self.chunk_walls: 169 | self.chunk_walls[chunk_str] = {} 170 | self.chunk_walls[chunk_str][wall_str] = wall 171 | 172 | def clear_walls(self): 173 | """ 174 | Delete all walls. 175 | """ 176 | self.walls = [] 177 | self.chunk_walls = {} 178 | 179 | def add_dynamic_walls(self, walls: List[Wall]) -> str: 180 | """ 181 | Add a dynamic wall and return the dynamic wall id. 182 | This works similarly to add_walls, it just uses a different system in the background that allows 183 | modification but performs worse. 184 | """ 185 | self.dynamic_wall_id += 1 186 | self.dynamic_walls[str(self.dynamic_wall_id)] = walls 187 | return str(self.dynamic_wall_id) 188 | 189 | def update_dynamic_walls(self, group_id: str, walls: List[Wall]): 190 | """ 191 | Set a group_id to contain a set of walls. Existing walls can be modified directly for better performance 192 | since objects use pointers. 193 | 194 | This is useful for overwriting the walls and takes walls in the same format as add_walls. 195 | """ 196 | self.dynamic_walls[group_id] = walls 197 | 198 | def delete_dynamic_walls(self, group_id: int): 199 | """ 200 | Delete a group of walls. 201 | """ 202 | del self.dynamic_walls[group_id] 203 | 204 | def add_light(self, light: Light) -> str: 205 | """ 206 | Create a new light with a light object. Returns the light id. 207 | """ 208 | self.light_id += 1 209 | self.lights[str(self.light_id)] = light 210 | return str(self.light_id) 211 | 212 | def get_light(self, light_id: str): 213 | """ 214 | Get a light object based on ID. 215 | Often used so that the position can then be modified. 216 | """ 217 | return self.lights[light_id] 218 | 219 | def delete_light(self, light_id: str): 220 | """ 221 | Delete a light. 222 | """ 223 | del self.lights[light_id] 224 | 225 | def _get_max_light_radius(self): 226 | """ 227 | Get max light radius. 228 | """ 229 | max_radius = 0 230 | for light in self.lights: 231 | max_radius = max(max_radius, self.lights[light].radius) 232 | 233 | return max_radius 234 | 235 | def render(self, target_surf: pygame.Surface, offset: Optional[List[int]] = None): 236 | """ 237 | The core rendering function that renders the lighting within the lightbox. The offset is used to specify 238 | the terrain offset (aka camera offset or scroll) of the game relative to the viewed area. 239 | 240 | The `target_surface` is the `pygame.Surface` that will have the lighting rendered onto it. If it is a black 241 | surface, you get the internal lighting mask. This can be useful for static lighting. However, normally the 242 | main display surface should be used. 243 | """ 244 | # avoid mutable default 245 | if offset is None: 246 | offset = [0, 0] 247 | assert isinstance(offset, list) 248 | 249 | # get the max light radius to determine which lights need to be rendered 250 | # if a light center is farther away from the viewing range than its radius, it's off the screen 251 | # if the light's modifications don't reach onto the screen, then its shadows won't have an effect, 252 | # so it's not necessary to process 253 | max_radius = self._get_max_light_radius() 254 | 255 | # define an updated render_box rect with respect to the terrain offset and the light range to determine which 256 | # walls needs to be processed 257 | render_box_r = pygame.Rect( 258 | -max_radius + offset[0], 259 | -max_radius + offset[1], 260 | self.vision_box_r.width + max_radius * 2, 261 | self.vision_box_r.height + max_radius * 2, 262 | ) 263 | 264 | # get all visible walls by using the chunk indexes 265 | valid_wall_dict = {} 266 | for y in range(self.vision_box_r.height // self.chunk_size + self.chunk_overshoot * 2 + 1): 267 | for x in range(self.vision_box_r.width // self.chunk_size + self.chunk_overshoot * 2 + 1): 268 | chunk_str = ( 269 | str(int(x - self.chunk_overshoot // 2 + offset[0] // self.chunk_size)) 270 | + ";" 271 | + str(int(y - self.chunk_overshoot // 2 + offset[1] // self.chunk_size)) 272 | ) 273 | if chunk_str in self.chunk_walls: 274 | valid_wall_dict.update(self.chunk_walls[chunk_str]) 275 | valid_walls = list(valid_wall_dict.values()) 276 | for group in self.dynamic_walls: 277 | valid_walls += self.dynamic_walls[group] 278 | 279 | # adjust for offset to get the "shown position" 280 | valid_walls = [ 281 | wall.clone_move([-offset[0], -offset[1]]) for wall in valid_walls if wall.rect.colliderect(render_box_r) 282 | ] 283 | 284 | # redefine the render_box rect with the terrain offset removed since the walls have been moved 285 | render_box_r = pygame.Rect( 286 | -max_radius, 287 | -max_radius, 288 | self.vision_box_r.width + max_radius * 2, 289 | self.vision_box_r.height + max_radius * 2, 290 | ) 291 | 292 | # generate a Surface to render the lighting mask onto 293 | rendered_mask = pygame.Surface(self.vision_box_r.size) 294 | 295 | # iterate through all of the lights 296 | for light in self.lights.values(): 297 | # apply the terrain offset 298 | light_pos = [light.position[0] - offset[0], light.position[1] - offset[1]] 299 | # check for visibility (don't forget that the current rect is adjusted for the radii of the lights) 300 | if render_box_r.collidepoint(light_pos): 301 | # apply lighting image 302 | light_instance_surf = light.light_img.copy() 303 | light_offset = [light_pos[0] - light.radius, light_pos[1] - light.radius] 304 | 305 | # draw over the light image with the shadows of each wall (the draw_shadow function only draws if 306 | # applicable, so a polygon isn't drawn every time) 307 | for wall in valid_walls: 308 | wall.draw_shadow(light_instance_surf, light_pos, render_box_r, (0, 0, 0), light_offset) 309 | 310 | # blit lighting mask onto main surface with RGBA_ADD so that the lighting can accumulate 311 | rendered_mask.blit(light_instance_surf, light_offset, special_flags=BLEND_RGBA_ADD) 312 | 313 | # update the light 314 | light.update() 315 | 316 | # blit the final lighting mask onto the target surface 317 | target_surf.blit(rendered_mask, (0, 0), special_flags=self.blit_flags) 318 | 319 | # return the list of visible walls in case they need to be used for anything 320 | return valid_walls 321 | 322 | 323 | class Wall: 324 | """ 325 | Handles shadow casting within a Lightbox. 326 | """ 327 | 328 | def __init__( 329 | self, 330 | p1: List[int], 331 | p2: List[int], 332 | vertical: int, 333 | direction: int, 334 | color: Tuple[int, int, int] = (255, 255, 255), 335 | ): 336 | self.p1 = p1 337 | self.p2 = p2 338 | 339 | # The vertical aspect of the wall that is used to determine direction for shadows (must be `1` or `0`). 340 | # Vertical refers to the direction of the face, not the direction of the wall, so if it's set to `1`, 341 | # the face is up/down and the line that makes up the wall is horizontal. 342 | self.vertical = vertical 343 | 344 | # The direction of the wall (inward/outward). This must be set to `-1` or `1`. The direction refers to the 345 | # axis the wall is on based on `Wall.vertical` with `-1` being associated with the negative direction on the 346 | # associated axis. 347 | self.direction = direction 348 | self.color: Tuple[int, int, int] = color 349 | 350 | # generate the rect for light_box collisions 351 | self.rect: pygame.Rect = self._create_rect() 352 | 353 | def clone_move(self, offset: Tuple[int, int]) -> Wall: 354 | """ 355 | Create a duplicate Wall with an offset. 356 | """ 357 | return Wall( 358 | [self.p1[0] + offset[0], self.p1[1] + offset[1]], 359 | [self.p2[0] + offset[0], self.p2[1] + offset[1]], 360 | self.vertical, 361 | self.direction, 362 | self.color, 363 | ) 364 | 365 | def _create_rect(self): 366 | """ 367 | Create a rect using the points in the wall 368 | """ 369 | r_p1 = [min(self.p1[0], self.p2[0]), min(self.p1[1], self.p2[1])] 370 | r_p2 = [max(self.p1[0], self.p2[0]), max(self.p1[1], self.p2[1])] 371 | # +1 in the x_size and y_size because straight walls have a width or height of 0 372 | 373 | return pygame.Rect(r_p1[0], r_p1[1], r_p2[0] - r_p1[0] + 1, r_p2[1] - r_p1[1] + 1) 374 | 375 | def _check_cast(self, source) -> int: 376 | # will return 1 (or True) if the direction/position of the wall logically allows a shadow to be cast 377 | if (source[self.vertical] - self.p1[self.vertical]) * self.direction < 0: 378 | return 1 379 | else: 380 | return 0 381 | 382 | @staticmethod 383 | def _determine_cast_endpoint(source, point, vision_box): 384 | """ 385 | Determine the point on the vision_box's edge that is collinear to the light and the endpoint of the Wall. 386 | This must be called for each endpoint of the wall. 387 | """ 388 | difx = source[0] - point[0] 389 | dify = source[1] - point[1] 390 | try: 391 | slope = dify / difx 392 | # questionable, but looks alright 393 | except ZeroDivisionError: 394 | slope = 999999 395 | if slope == 0: 396 | slope = 0.000001 397 | 398 | # since the vision_box's edges are being treated as lines, there are technically 2 collinear points on the 399 | # vision box's edge 400 | # one must be a horizontal side and the other must be vertical since the 2 points must be on adjacent sides 401 | 402 | # determine which horizontal and which vertical sides of the vision box are used (top/bottom and left/right) 403 | cast_hside = 0 404 | cast_vside = 0 405 | if difx < 0: 406 | cast_hside = 1 407 | if dify < 0: 408 | cast_vside = 1 409 | 410 | # calculate the collinear points with quick mafs 411 | if cast_hside: 412 | hwall_p = [vision_box.right, slope * (vision_box.right - source[0]) + source[1]] 413 | else: 414 | hwall_p = [vision_box.left, slope * (vision_box.left - source[0]) + source[1]] 415 | if cast_vside: 416 | vwall_p = [(vision_box.bottom - source[1]) / slope + source[0], vision_box.bottom] 417 | else: 418 | vwall_p = [(vision_box.top - source[1]) / slope + source[0], vision_box.top] 419 | 420 | # calculate closer point out of the 2 collinear points and return side used 421 | if (abs(hwall_p[0] - source[0]) + abs(hwall_p[1] - source[1])) < ( 422 | abs(vwall_p[0] - source[0]) + abs(vwall_p[1] - source[1]) 423 | ): 424 | # horizontal sides use numbers 2 and 3 425 | return hwall_p, cast_hside + 2 426 | else: 427 | # vertical sides use numbers 0 and 1 428 | return vwall_p, cast_vside 429 | 430 | def _get_intermediate_points(self, p1_side, p2_side, vision_box): 431 | """ 432 | Get the corner points for the polygon. 433 | If the casted shadow points for walls are on different vision_box sides, the corners between the points must 434 | be added. 435 | """ 436 | # the "sides" refer to the sides of the vision_box that the wall endpoints casted onto 437 | # 0 = top, 1 = bottom, 2 = left, 3 = right 438 | sides = [p1_side, p2_side] 439 | sides.sort() 440 | # return the appropriate sides based on the 2 sides 441 | # the first 4 are the cases where the 2 shadow points are on adjacent sides 442 | if sides == [0, 3]: 443 | return [vision_box.topright] 444 | elif sides == [1, 3]: 445 | return [vision_box.bottomright] 446 | elif sides == [1, 2]: 447 | return [vision_box.bottomleft] 448 | elif sides == [0, 2]: 449 | return [vision_box.topleft] 450 | # these 2 are for when the shadow points are on opposite sides (normally happens when the light source is 451 | # close to the wall) 452 | # the intermediate points depend on the direction the shadow was cast in this case (they could be on either 453 | # side without knowing the direction) 454 | elif sides == [0, 1]: 455 | if self.direction == -1: 456 | return [vision_box.topleft, vision_box.bottomleft] 457 | else: 458 | return [vision_box.topright, vision_box.bottomright] 459 | elif sides == [2, 3]: 460 | if self.direction == -1: 461 | return [vision_box.topleft, vision_box.topright] 462 | else: 463 | return [vision_box.bottomleft, vision_box.bottomright] 464 | # this happens if the sides are the same, which would mean the shadow doesn't cross sides and has no 465 | # intermediate points 466 | else: 467 | return [] 468 | 469 | def draw_shadow( 470 | self, 471 | surf: pygame.Surface, 472 | source: List[int], 473 | vision_box: pygame.Rect, 474 | color: Tuple[int, int, int], 475 | offset: Optional[List[int]] = None, 476 | ): 477 | """ 478 | Draw a shadow, as cast by the light source. 479 | 480 | Primarily used internally by the `LightBox` class, but it's available for independent use if you want to do 481 | something crazy. In this context, `light_source` is point (`[x, y]`), not a `Light` object. The `vision_box` 482 | is just a `pygame.Rect` that specifies the visible area. The `color` is the color of the shadow. In normal 483 | use, the shadow is black and used to create a mask, but you can do some weird stuff by changing the color. 484 | """ 485 | # avoid mutable default 486 | if offset is None: 487 | offset = [0, 0] 488 | assert isinstance(offset, list) 489 | 490 | # check if a shadow needs to be casted 491 | if self._check_cast(source): 492 | 493 | # calculate the endpoints of the shadow when casted on the edge of the vision_box 494 | p1_shadow, p1_side = self._determine_cast_endpoint(source, self.p1, vision_box) 495 | p2_shadow, p2_side = self._determine_cast_endpoint(source, self.p2, vision_box) 496 | 497 | # calculate the intermediate points of the shadow (see the function for a more detailed description) 498 | intermediate_points = self._get_intermediate_points(p1_side, p2_side, vision_box) 499 | 500 | # arrange the points of the polygon 501 | points = [self.p1] + [p1_shadow] + intermediate_points + [p2_shadow] + [self.p2] 502 | 503 | # apply offset 504 | points = [[p[0] - offset[0], p[1] - offset[1]] for p in points] 505 | 506 | # draw the polygon 507 | pygame.draw.polygon(surf, color, points) 508 | 509 | def render(self, surf: pygame.Surface, offset: Optional[List[int]] = None): 510 | """ 511 | Render the line that makes up the wall. 512 | Mostly just useful for debugging. 513 | """ 514 | # avoid mutable default 515 | if offset is None: 516 | offset = [0, 0] 517 | assert isinstance(offset, list) 518 | 519 | pygame.draw.line( 520 | surf, 521 | self.color, 522 | [self.p1[0] + offset[0], self.p1[1] + offset[1]], 523 | [self.p2[0] + offset[0], self.p2[1] + offset[1]], 524 | ) 525 | 526 | 527 | def box(pos: List[int], size: List[int]): 528 | """ 529 | Generate a box of Walls with all walls facing outwards. The pos is the top left of the box. This list of walls can 530 | be added to a LightBox using LightBox.add_walls. Useful for custom wall generation. 531 | """ 532 | walls = [] 533 | walls.append(Wall([pos[0], pos[1]], [pos[0] + size[0], pos[1]], 1, -1)) 534 | walls.append(Wall([pos[0], pos[1]], [pos[0], pos[1] + size[1]], 0, -1)) 535 | walls.append(Wall([pos[0] + size[0], pos[1]], [pos[0] + size[0], pos[1] + size[1]], 0, 1)) 536 | walls.append(Wall([pos[0], pos[1] + size[1]], [pos[0] + size[0], pos[1] + size[1]], 1, 1)) 537 | return walls 538 | 539 | 540 | def point_str(point) -> str: 541 | """ 542 | Convert a point to a string 543 | """ 544 | # some string conversion functions (since looking up strings in a dict is pretty fast performance-wise) 545 | return str(point[0]) + ";" + str(point[1]) 546 | 547 | 548 | def line_str(line, point) -> str: 549 | """ 550 | Convert a line to a string 551 | """ 552 | return point_str(line[point]) + ";" + str(line[2][0]) + ";" + str(line[2][1]) 553 | 554 | 555 | def str_point(string: str): 556 | """ 557 | Convert string to point 558 | """ 559 | return [int(v) for v in string.split(";")[:2]] 560 | 561 | 562 | def set_mask_alpha(surf: pygame.Surface, alpha: int) -> pygame.Surface: 563 | """ 564 | Set the alpha of the screen mask 565 | """ 566 | return mult_color(surf, (alpha, alpha, alpha)) 567 | 568 | 569 | def mult_color(surf: pygame.Surface, color: Tuple[int, int, int]) -> pygame.Surface: 570 | """ 571 | Multiply the color given on the provided surface. 572 | """ 573 | mult_surf = surf.copy() 574 | mult_surf.fill(color) 575 | new_surf = surf.copy() 576 | new_surf.blit(mult_surf, (0, 0), special_flags=BLEND_RGBA_MULT) 577 | return new_surf 578 | 579 | 580 | def get_chunk(point, chunk_size): 581 | return [point[0] // chunk_size, point[1] // chunk_size] 582 | 583 | 584 | def generate_walls(light_box: LightBox, map_data: List[List[int]], tile_size: int) -> List[Wall]: 585 | """ 586 | Adds walls to the designated light box using a list of "air" (empty) tiles. 587 | 588 | Bordering sides will be joined together to reduce the wall count. The tile locations in the map_data should be 589 | the grid positions. The positions are then multiplied by the tile_size to get the pixel positions of the 590 | tiles along with the coordinates of the sides. The returned data is just a list of Wall objects that were 591 | added to the given LightBox. 592 | """ 593 | # looking up a string in a dict is significantly quicker than looking up in a list 594 | map_dict = {} 595 | lines = [] 596 | 597 | # generate a dict with all of the tiles 598 | for tile in map_data: 599 | map_dict[str(tile[0]) + ";" + str(tile[1])] = 1 600 | 601 | # add all the walls by checking air tiles for bordering solid tiles (solid tiles are where there are no tiles in 602 | # the dict) 603 | for air_tile in map_data: 604 | # check all sides for each air tile 605 | if point_str([air_tile[0] + 1, air_tile[1]]) not in map_dict: 606 | # generate line in [p1, p2, [vertical, inside/outside]] format 607 | lines.append( 608 | [ 609 | [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size], 610 | [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size + tile_size], 611 | [0, -1], 612 | ] 613 | ) 614 | if point_str([air_tile[0] - 1, air_tile[1]]) not in map_dict: 615 | lines.append( 616 | [ 617 | [air_tile[0] * tile_size, air_tile[1] * tile_size], 618 | [air_tile[0] * tile_size, air_tile[1] * tile_size + tile_size], 619 | [0, 1], 620 | ] 621 | ) 622 | if point_str([air_tile[0], air_tile[1] + 1]) not in map_dict: 623 | lines.append( 624 | [ 625 | [air_tile[0] * tile_size, air_tile[1] * tile_size + tile_size], 626 | [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size + tile_size], 627 | [1, -1], 628 | ] 629 | ) 630 | if point_str([air_tile[0], air_tile[1] - 1]) not in map_dict: 631 | lines.append( 632 | [ 633 | [air_tile[0] * tile_size, air_tile[1] * tile_size], 634 | [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size], 635 | [1, 1], 636 | ] 637 | ) 638 | 639 | # reformat the data into a useful form for the geometry tricks later 640 | # this adds each endpoint to a dict as a key with the associated endpoint being in the list of associated values 641 | # (so 1 point can link to 2 bordering points where lines are connected) 642 | # it keys with respect to the vertical/horizontal aspect and the inward/outward aspect, so all lines that use the 643 | # same keys are part of a single joined line 644 | line_dict: Dict[str, List[str]] = {} 645 | for line in lines: 646 | for i in range(2): 647 | if line_str(line, i) in line_dict: 648 | line_dict[line_str(line, i)].append(line_str(line, 1 - i)) 649 | else: 650 | line_dict[line_str(line, i)] = [line_str(line, 1 - i)] 651 | 652 | final_walls = [] 653 | # keep track of the processed points so that those keys can be ignored (we add 4 points per line since each point 654 | # must be a key once and a value once) 655 | processed_points = [] 656 | for point in line_dict: 657 | # the length of the items in this dict are the number of connected points 658 | # so if there's only 1 connected point, that means it's the end of a line 659 | # we can then follow the line's points to calculate the single line based off the connections 660 | if point not in processed_points: 661 | # look for the end of the line and skip all the others (since anything else must be connected to an end 662 | # due to the respect to direction) 663 | if len(line_dict[point]) == 1: 664 | # add this point to the list to ignore 665 | processed_points.append(point) 666 | offset = 1 667 | p1 = str_point(point) 668 | p2 = str_point(line_dict[point][0]) 669 | # calculate the direction based on the 2 points 670 | direction = [(p2[0] - p1[0]) // tile_size, (p2[1] - p1[1]) // tile_size] 671 | # loop through the connected points until the other end is found 672 | while 1: 673 | # generate the string for the next point 674 | target_pos = ( 675 | str(p1[0] + direction[0] * offset * tile_size) 676 | + ";" 677 | + str(p1[1] + direction[1] * offset * tile_size) 678 | + ";" 679 | + point.split(";")[2] 680 | + ";" 681 | + point.split(";")[3] 682 | ) 683 | # when the connected point only links to 1 point, you've found the other end of the line 684 | processed_points.append(target_pos) 685 | if len(line_dict[target_pos]) == 1: 686 | break 687 | offset += 1 688 | # append to the walls list based on the last point found and the starting point 689 | final_walls.append([p1, str_point(target_pos), int(point.split(";")[2]), int(point.split(";")[3])]) 690 | 691 | # correct overshot edges (must be done after grouping for proper results) and generate Wall objects 692 | for wall in final_walls: 693 | grid_pos_x = wall[0][0] 694 | grid_pos_y = wall[0][1] 695 | 696 | # get tile location of wall 697 | tile_x = int(grid_pos_x // tile_size) 698 | tile_y = int(grid_pos_y // tile_size) 699 | 700 | # check for relevant bordering tiles to determine if it's okay to shorten the wall 701 | if not wall[2]: 702 | if wall[3] == 1: 703 | if [tile_x, tile_y + 1] in map_data: 704 | wall[1][1] -= 1 705 | else: 706 | if [tile_x - 1, tile_y + 1] in map_data: 707 | wall[1][1] -= 1 708 | 709 | # move right-facing wall inward 710 | if wall[3] == 1: 711 | wall[0][0] -= 1 712 | wall[1][0] -= 1 713 | else: 714 | if wall[3] == 1: 715 | if [tile_x + 1, tile_y] in map_data: 716 | wall[1][0] -= 1 717 | else: 718 | if [tile_x + 1, tile_y - 1] in map_data: 719 | wall[1][0] -= 1 720 | 721 | # move downward-facing wall inward 722 | if wall[3] == 1: 723 | wall[0][1] -= 1 724 | wall[1][1] -= 1 725 | 726 | # generate Wall objects 727 | _final_walls = [Wall(*wall) for wall in final_walls] 728 | 729 | # apply walls 730 | light_box.add_walls(_final_walls) 731 | 732 | # return the list just in case it's needed for something 733 | return _final_walls 734 | -------------------------------------------------------------------------------- /example/map.txt: -------------------------------------------------------------------------------- 1 | 000000000000000000000000000000000000 2 | 011100000000000000000000000000000000 3 | 000000000000000110000000000000111000 4 | 000000000000000000000000000000001000 5 | 000000000011000000000000001000000000 6 | 000011111111000000000000001000000000 7 | 000000000000100000000001111111000000 8 | 010000000000000100000000001000000000 9 | 000000000000000000000000001000000000 10 | 000000000000000010000000000000000000 11 | 000011100001110000000000000000000000 12 | 000001000001010000000000000000011000 13 | 000001000001110000000000000000000000 14 | 000001000000010000000000000000000000 15 | 000001000000100000000000000111110000 16 | 000000000000000000000000000000001000 17 | 000000000000100000000000000000101000 18 | 000010000000110000000000000001110000 19 | 000000000000000000000000000000000000 20 | 000000010000000000000000000000000000 21 | 000000000000011111111111110000000000 22 | 000000000000100000000000010000000000 23 | 000000000001000000000111010000000000 24 | 000110000000000000000100010000000000 25 | 000110000000000000000111110000000000 26 | 000000000001111000000000000000000000 27 | 000000000111111000000000000000000000 28 | 000000001111111100000000000000010000 29 | 000011111111111111111000000000000000 30 | 000000000000000000000000000000000000 -------------------------------------------------------------------------------- /lighting.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import random 5 | from typing import Dict, List, Optional, Tuple 6 | 7 | import pygame 8 | from pygame import BLEND_RGBA_ADD, BLEND_RGBA_MULT 9 | 10 | __all__ = ["Light", "LightBox", "Wall"] 11 | 12 | 13 | class Light: 14 | """ 15 | Holds the attributes for the light and offers some basic interface instructions. 16 | """ 17 | 18 | def __init__( 19 | self, 20 | pos: List[int], 21 | radius: int, 22 | light_img: pygame.Surface, 23 | color: Tuple[int, int, int] = (255, 255, 255), 24 | alpha: int = 255, 25 | ): 26 | self._base_position: List[int] = pos # screen position 27 | self.position: List[int] = pos 28 | self._base_radius: int = radius # screen size 29 | self.radius: int = radius 30 | self._base_light_img: pygame.Surface = pygame.transform.scale(light_img, (radius * 2, radius * 2)) 31 | self._colored_light_img: pygame.Surface = self._base_light_img.copy() 32 | self.light_img: pygame.Surface = self._base_light_img.copy() 33 | self.alpha: int = alpha 34 | self.color: Tuple[int, int, int] = color 35 | self.timer: int = 1 # timer for wave/pule of light 36 | self.flicker_timer: int = 1 # timer for jumping flicker 37 | self.variance = 0 # how much variance from radius due to flicker 38 | self.variance_size = int(self._base_radius / 30) 39 | 40 | self._calculate_light_img() 41 | 42 | def update(self): 43 | base_radius = self._base_radius 44 | variance_size = self.variance_size 45 | 46 | # increment wave timer 47 | self.timer += 1 48 | self.set_size(int((1 + math.sin(self.timer / 10)) + (base_radius + self.variance))) 49 | 50 | # decrement flicker timer 51 | self.flicker_timer -= 1 52 | 53 | # update for flickering effect 54 | if self.flicker_timer < 0: 55 | # scale size 56 | self.variance = random.randint(-variance_size, variance_size) 57 | radius = base_radius + self.variance 58 | self.set_size(radius) 59 | 60 | # alpha variance 61 | alpha_variance = int(self.variance) 62 | self.set_alpha(max(0, min(255, self.alpha + alpha_variance))) 63 | 64 | # set new timer 65 | self.flicker_timer = random.randint(30, 60) 66 | 67 | def _calculate_light_img(self): 68 | """ 69 | Alter the original light image by all of the attributes given, e.g. alpha, color, etc. 70 | """ 71 | self._colored_light_img = mult_color(set_mask_alpha(self._base_light_img, self.alpha), self.color) 72 | self.light_img = self._colored_light_img.copy() 73 | 74 | def set_alpha(self, alpha: int): 75 | """ 76 | Set the alpha value of the light. Refreshes the mask and size. 77 | """ 78 | self.alpha = alpha 79 | self._colored_light_img = set_mask_alpha(self._base_light_img, self.alpha) 80 | self.set_size(self.radius) 81 | 82 | def set_color(self, color: Tuple[int, int, int], override_alpha: bool = False): 83 | """ 84 | Set the color of the light. Refreshes the size. If `override_alpha` is set to `True`, the alpha setting is 85 | ignored when recalculating the light. This is better for performance. 86 | """ 87 | self.color = color 88 | if override_alpha: 89 | self._colored_light_img = mult_color(self._base_light_img, self.color) 90 | else: 91 | self._calculate_light_img() 92 | self.set_size(self.radius) 93 | 94 | def set_size(self, radius: int): 95 | """ 96 | Set the size of the light and rescale the image to match. 97 | """ 98 | self.radius = radius 99 | self.light_img = pygame.transform.scale(self._colored_light_img, (radius * 2, radius * 2)) 100 | 101 | 102 | class LightBox: 103 | """ 104 | Handles the processing of Lights. 105 | The name "LightBox" comes from the idea that the lighting is only rendered within the "box" of the display. 106 | 107 | The dimensions provided upon initialization will usually be the dimensions of your display. The `blit_flags` 108 | argument determines the blitting flags used in `LightBox.render()`. By default, it uses the adding flags which 109 | adds light values. However, other flags such as `BLEND_RGBA_MULT` can be used. `BLEND_RGBA_MULT` multiplies the 110 | destination surface by the lighting surface which results in darkening the areas that aren't lit up instead of 111 | brightening the areas that are. 112 | """ 113 | 114 | def __init__(self, size: Tuple[int, int], blit_flags: int = BLEND_RGBA_ADD): 115 | self.vision_box_r: pygame.Rect = pygame.Rect(0, 0, size[0], size[1]) 116 | # The position of this rect should be `[0, 0]` as the top left of a window's coordinates are also `[0, 117 | # 0]`. Terrain offset is applied elsewhere. We can adjust the size of this rect to adjust the size of your 118 | # visible area using the `pygame.Rect.width` and the `pygame.Rect.height` attributes. 119 | 120 | self.walls: List[Wall] = [] 121 | 122 | # dict for storing walls by chunk 123 | self.chunk_walls: Dict = {} 124 | # size of chunks (tweak for better performance) 125 | self.chunk_size: int = 80 126 | # the amount of extra chunks in each direction the game should process (necessary for large lights outside 127 | # the light box) 128 | self.chunk_overshoot: int = 1 129 | 130 | # dict for storing dynamic walls 131 | self.dynamic_walls: Dict = {} 132 | # keeps track of the IDs for dynamic walls so they have different IDs 133 | self.dynamic_wall_id: int = 0 134 | 135 | self.lights: Dict[str, Light] = {} 136 | 137 | # keeps track of IDs for lights so that each new light has a different ID 138 | # converted to str when added to dict 139 | self.light_id: int = 0 140 | 141 | self.blit_flags: int = blit_flags 142 | 143 | def add_walls(self, walls: List[Wall]): 144 | """ 145 | Add walls to the lightbox. 146 | This is used for manual wall additions, however, typically, walls are loaded using generate_walls. 147 | """ 148 | self.walls += walls 149 | 150 | # split walls into all the chunks they cross 151 | # this creates duplicates, but the name keys can be put into a dict to efficiently remove duplicates 152 | for wall in walls: 153 | wall_str = point_str(wall.p1) + ";" + point_str(wall.p2) + ";" + str(wall.direction) 154 | p1_chunk = get_chunk(wall.p1, self.chunk_size) 155 | p2_chunk = get_chunk(wall.p2, self.chunk_size) 156 | chunk_list = [] 157 | if p1_chunk != p2_chunk: 158 | if abs(p1_chunk[0] - p2_chunk[0]) > 0: 159 | for i in range(max(p1_chunk[0], p2_chunk[0]) - min(p1_chunk[0], p2_chunk[0]) + 1): 160 | chunk_list.append([min(p1_chunk[0], p2_chunk[0]) + i, p1_chunk[1]]) 161 | elif abs(p1_chunk[1] - p2_chunk[1]) > 0: 162 | for i in range(max(p1_chunk[1], p2_chunk[1]) - min(p1_chunk[1], p2_chunk[1]) + 1): 163 | chunk_list.append([p1_chunk[0], min(p1_chunk[1], p2_chunk[1]) + i]) 164 | else: 165 | chunk_list = [p1_chunk, p2_chunk] 166 | for chunk in chunk_list: 167 | chunk_str = point_str(chunk) 168 | if chunk_str not in self.chunk_walls: 169 | self.chunk_walls[chunk_str] = {} 170 | self.chunk_walls[chunk_str][wall_str] = wall 171 | 172 | def clear_walls(self): 173 | """ 174 | Delete all walls. 175 | """ 176 | self.walls = [] 177 | self.chunk_walls = {} 178 | 179 | def add_dynamic_walls(self, walls: List[Wall]) -> str: 180 | """ 181 | Add a dynamic wall and return the dynamic wall id. 182 | This works similarly to add_walls, it just uses a different system in the background that allows 183 | modification but performs worse. 184 | """ 185 | self.dynamic_wall_id += 1 186 | self.dynamic_walls[str(self.dynamic_wall_id)] = walls 187 | return str(self.dynamic_wall_id) 188 | 189 | def update_dynamic_walls(self, group_id: str, walls: List[Wall]): 190 | """ 191 | Set a group_id to contain a set of walls. Existing walls can be modified directly for better performance 192 | since objects use pointers. 193 | 194 | This is useful for overwriting the walls and takes walls in the same format as add_walls. 195 | """ 196 | self.dynamic_walls[group_id] = walls 197 | 198 | def delete_dynamic_walls(self, group_id: int): 199 | """ 200 | Delete a group of walls. 201 | """ 202 | del self.dynamic_walls[group_id] 203 | 204 | def add_light(self, light: Light) -> str: 205 | """ 206 | Create a new light with a light object. Returns the light id. 207 | """ 208 | self.light_id += 1 209 | self.lights[str(self.light_id)] = light 210 | return str(self.light_id) 211 | 212 | def get_light(self, light_id: str): 213 | """ 214 | Get a light object based on ID. 215 | Often used so that the position can then be modified. 216 | """ 217 | return self.lights[light_id] 218 | 219 | def delete_light(self, light_id: str): 220 | """ 221 | Delete a light. 222 | """ 223 | del self.lights[light_id] 224 | 225 | def _get_max_light_radius(self): 226 | """ 227 | Get max light radius. 228 | """ 229 | max_radius = 0 230 | for light in self.lights: 231 | max_radius = max(max_radius, self.lights[light].radius) 232 | 233 | return max_radius 234 | 235 | def render(self, target_surf: pygame.Surface, offset: Optional[List[int]] = None): 236 | """ 237 | The core rendering function that renders the lighting within the lightbox. The offset is used to specify 238 | the terrain offset (aka camera offset or scroll) of the game relative to the viewed area. 239 | 240 | The `target_surface` is the `pygame.Surface` that will have the lighting rendered onto it. If it is a black 241 | surface, you get the internal lighting mask. This can be useful for static lighting. However, normally the 242 | main display surface should be used. 243 | """ 244 | # avoid mutable default 245 | if offset is None: 246 | offset = [0, 0] 247 | assert isinstance(offset, list) 248 | 249 | # get the max light radius to determine which lights need to be rendered 250 | # if a light center is farther away from the viewing range than its radius, it's off the screen 251 | # if the light's modifications don't reach onto the screen, then its shadows won't have an effect, 252 | # so it's not necessary to process 253 | max_radius = self._get_max_light_radius() 254 | 255 | # define an updated render_box rect with respect to the terrain offset and the light range to determine which 256 | # walls needs to be processed 257 | render_box_r = pygame.Rect( 258 | -max_radius + offset[0], 259 | -max_radius + offset[1], 260 | self.vision_box_r.width + max_radius * 2, 261 | self.vision_box_r.height + max_radius * 2, 262 | ) 263 | 264 | # get all visible walls by using the chunk indexes 265 | valid_wall_dict = {} 266 | for y in range(self.vision_box_r.height // self.chunk_size + self.chunk_overshoot * 2 + 1): 267 | for x in range(self.vision_box_r.width // self.chunk_size + self.chunk_overshoot * 2 + 1): 268 | chunk_str = ( 269 | str(int(x - self.chunk_overshoot // 2 + offset[0] // self.chunk_size)) 270 | + ";" 271 | + str(int(y - self.chunk_overshoot // 2 + offset[1] // self.chunk_size)) 272 | ) 273 | if chunk_str in self.chunk_walls: 274 | valid_wall_dict.update(self.chunk_walls[chunk_str]) 275 | valid_walls = list(valid_wall_dict.values()) 276 | for group in self.dynamic_walls: 277 | valid_walls += self.dynamic_walls[group] 278 | 279 | # adjust for offset to get the "shown position" 280 | valid_walls = [ 281 | wall.clone_move([-offset[0], -offset[1]]) for wall in valid_walls if wall.rect.colliderect(render_box_r) 282 | ] 283 | 284 | # redefine the render_box rect with the terrain offset removed since the walls have been moved 285 | render_box_r = pygame.Rect( 286 | -max_radius, 287 | -max_radius, 288 | self.vision_box_r.width + max_radius * 2, 289 | self.vision_box_r.height + max_radius * 2, 290 | ) 291 | 292 | # generate a Surface to render the lighting mask onto 293 | rendered_mask = pygame.Surface(self.vision_box_r.size) 294 | 295 | # iterate through all of the lights 296 | for light in self.lights.values(): 297 | # apply the terrain offset 298 | light_pos = [light.position[0] - offset[0], light.position[1] - offset[1]] 299 | # check for visibility (don't forget that the current rect is adjusted for the radii of the lights) 300 | if render_box_r.collidepoint(light_pos): 301 | # apply lighting image 302 | light_instance_surf = light.light_img.copy() 303 | light_offset = [light_pos[0] - light.radius, light_pos[1] - light.radius] 304 | 305 | # draw over the light image with the shadows of each wall (the draw_shadow function only draws if 306 | # applicable, so a polygon isn't drawn every time) 307 | for wall in valid_walls: 308 | wall.draw_shadow(light_instance_surf, light_pos, render_box_r, (0, 0, 0), light_offset) 309 | 310 | # blit lighting mask onto main surface with RGBA_ADD so that the lighting can accumulate 311 | rendered_mask.blit(light_instance_surf, light_offset, special_flags=BLEND_RGBA_ADD) 312 | 313 | # update the light 314 | light.update() 315 | 316 | # blit the final lighting mask onto the target surface 317 | target_surf.blit(rendered_mask, (0, 0), special_flags=self.blit_flags) 318 | 319 | # return the list of visible walls in case they need to be used for anything 320 | return valid_walls 321 | 322 | 323 | class Wall: 324 | """ 325 | Handles shadow casting within a Lightbox. 326 | """ 327 | 328 | def __init__( 329 | self, 330 | p1: List[int], 331 | p2: List[int], 332 | vertical: int, 333 | direction: int, 334 | color: Tuple[int, int, int] = (255, 255, 255), 335 | ): 336 | self.p1 = p1 337 | self.p2 = p2 338 | 339 | # The vertical aspect of the wall that is used to determine direction for shadows (must be `1` or `0`). 340 | # Vertical refers to the direction of the face, not the direction of the wall, so if it's set to `1`, 341 | # the face is up/down and the line that makes up the wall is horizontal. 342 | self.vertical = vertical 343 | 344 | # The direction of the wall (inward/outward). This must be set to `-1` or `1`. The direction refers to the 345 | # axis the wall is on based on `Wall.vertical` with `-1` being associated with the negative direction on the 346 | # associated axis. 347 | self.direction = direction 348 | self.color: Tuple[int, int, int] = color 349 | 350 | # generate the rect for light_box collisions 351 | self.rect: pygame.Rect = self._create_rect() 352 | 353 | def clone_move(self, offset: Tuple[int, int]) -> Wall: 354 | """ 355 | Create a duplicate Wall with an offset. 356 | """ 357 | return Wall( 358 | [self.p1[0] + offset[0], self.p1[1] + offset[1]], 359 | [self.p2[0] + offset[0], self.p2[1] + offset[1]], 360 | self.vertical, 361 | self.direction, 362 | self.color, 363 | ) 364 | 365 | def _create_rect(self): 366 | """ 367 | Create a rect using the points in the wall 368 | """ 369 | r_p1 = [min(self.p1[0], self.p2[0]), min(self.p1[1], self.p2[1])] 370 | r_p2 = [max(self.p1[0], self.p2[0]), max(self.p1[1], self.p2[1])] 371 | # +1 in the x_size and y_size because straight walls have a width or height of 0 372 | 373 | return pygame.Rect(r_p1[0], r_p1[1], r_p2[0] - r_p1[0] + 1, r_p2[1] - r_p1[1] + 1) 374 | 375 | def _check_cast(self, source) -> int: 376 | # will return 1 (or True) if the direction/position of the wall logically allows a shadow to be cast 377 | if (source[self.vertical] - self.p1[self.vertical]) * self.direction < 0: 378 | return 1 379 | else: 380 | return 0 381 | 382 | @staticmethod 383 | def _determine_cast_endpoint(source, point, vision_box): 384 | """ 385 | Determine the point on the vision_box's edge that is collinear to the light and the endpoint of the Wall. 386 | This must be called for each endpoint of the wall. 387 | """ 388 | difx = source[0] - point[0] 389 | dify = source[1] - point[1] 390 | try: 391 | slope = dify / difx 392 | # questionable, but looks alright 393 | except ZeroDivisionError: 394 | slope = 999999 395 | if slope == 0: 396 | slope = 0.000001 397 | 398 | # since the vision_box's edges are being treated as lines, there are technically 2 collinear points on the 399 | # vision box's edge 400 | # one must be a horizontal side and the other must be vertical since the 2 points must be on adjacent sides 401 | 402 | # determine which horizontal and which vertical sides of the vision box are used (top/bottom and left/right) 403 | cast_hside = 0 404 | cast_vside = 0 405 | if difx < 0: 406 | cast_hside = 1 407 | if dify < 0: 408 | cast_vside = 1 409 | 410 | # calculate the collinear points with quick mafs 411 | if cast_hside: 412 | hwall_p = [vision_box.right, slope * (vision_box.right - source[0]) + source[1]] 413 | else: 414 | hwall_p = [vision_box.left, slope * (vision_box.left - source[0]) + source[1]] 415 | if cast_vside: 416 | vwall_p = [(vision_box.bottom - source[1]) / slope + source[0], vision_box.bottom] 417 | else: 418 | vwall_p = [(vision_box.top - source[1]) / slope + source[0], vision_box.top] 419 | 420 | # calculate closer point out of the 2 collinear points and return side used 421 | if (abs(hwall_p[0] - source[0]) + abs(hwall_p[1] - source[1])) < ( 422 | abs(vwall_p[0] - source[0]) + abs(vwall_p[1] - source[1]) 423 | ): 424 | # horizontal sides use numbers 2 and 3 425 | return hwall_p, cast_hside + 2 426 | else: 427 | # vertical sides use numbers 0 and 1 428 | return vwall_p, cast_vside 429 | 430 | def _get_intermediate_points(self, p1_side, p2_side, vision_box): 431 | """ 432 | Get the corner points for the polygon. 433 | If the casted shadow points for walls are on different vision_box sides, the corners between the points must 434 | be added. 435 | """ 436 | # the "sides" refer to the sides of the vision_box that the wall endpoints casted onto 437 | # 0 = top, 1 = bottom, 2 = left, 3 = right 438 | sides = [p1_side, p2_side] 439 | sides.sort() 440 | # return the appropriate sides based on the 2 sides 441 | # the first 4 are the cases where the 2 shadow points are on adjacent sides 442 | if sides == [0, 3]: 443 | return [vision_box.topright] 444 | elif sides == [1, 3]: 445 | return [vision_box.bottomright] 446 | elif sides == [1, 2]: 447 | return [vision_box.bottomleft] 448 | elif sides == [0, 2]: 449 | return [vision_box.topleft] 450 | # these 2 are for when the shadow points are on opposite sides (normally happens when the light source is 451 | # close to the wall) 452 | # the intermediate points depend on the direction the shadow was cast in this case (they could be on either 453 | # side without knowing the direction) 454 | elif sides == [0, 1]: 455 | if self.direction == -1: 456 | return [vision_box.topleft, vision_box.bottomleft] 457 | else: 458 | return [vision_box.topright, vision_box.bottomright] 459 | elif sides == [2, 3]: 460 | if self.direction == -1: 461 | return [vision_box.topleft, vision_box.topright] 462 | else: 463 | return [vision_box.bottomleft, vision_box.bottomright] 464 | # this happens if the sides are the same, which would mean the shadow doesn't cross sides and has no 465 | # intermediate points 466 | else: 467 | return [] 468 | 469 | def draw_shadow( 470 | self, 471 | surf: pygame.Surface, 472 | source: List[int], 473 | vision_box: pygame.Rect, 474 | color: Tuple[int, int, int], 475 | offset: Optional[List[int]] = None, 476 | ): 477 | """ 478 | Draw a shadow, as cast by the light source. 479 | 480 | Primarily used internally by the `LightBox` class, but it's available for independent use if you want to do 481 | something crazy. In this context, `light_source` is point (`[x, y]`), not a `Light` object. The `vision_box` 482 | is just a `pygame.Rect` that specifies the visible area. The `color` is the color of the shadow. In normal 483 | use, the shadow is black and used to create a mask, but you can do some weird stuff by changing the color. 484 | """ 485 | # avoid mutable default 486 | if offset is None: 487 | offset = [0, 0] 488 | assert isinstance(offset, list) 489 | 490 | # check if a shadow needs to be casted 491 | if self._check_cast(source): 492 | 493 | # calculate the endpoints of the shadow when casted on the edge of the vision_box 494 | p1_shadow, p1_side = self._determine_cast_endpoint(source, self.p1, vision_box) 495 | p2_shadow, p2_side = self._determine_cast_endpoint(source, self.p2, vision_box) 496 | 497 | # calculate the intermediate points of the shadow (see the function for a more detailed description) 498 | intermediate_points = self._get_intermediate_points(p1_side, p2_side, vision_box) 499 | 500 | # arrange the points of the polygon 501 | points = [self.p1] + [p1_shadow] + intermediate_points + [p2_shadow] + [self.p2] 502 | 503 | # apply offset 504 | points = [[p[0] - offset[0], p[1] - offset[1]] for p in points] 505 | 506 | # draw the polygon 507 | pygame.draw.polygon(surf, color, points) 508 | 509 | def render(self, surf: pygame.Surface, offset: Optional[List[int]] = None): 510 | """ 511 | Render the line that makes up the wall. 512 | Mostly just useful for debugging. 513 | """ 514 | # avoid mutable default 515 | if offset is None: 516 | offset = [0, 0] 517 | assert isinstance(offset, list) 518 | 519 | pygame.draw.line( 520 | surf, 521 | self.color, 522 | [self.p1[0] + offset[0], self.p1[1] + offset[1]], 523 | [self.p2[0] + offset[0], self.p2[1] + offset[1]], 524 | ) 525 | 526 | 527 | def box(pos: List[int], size: List[int]): 528 | """ 529 | Generate a box of Walls with all walls facing outwards. The pos is the top left of the box. This list of walls can 530 | be added to a LightBox using LightBox.add_walls. Useful for custom wall generation. 531 | """ 532 | walls = [] 533 | walls.append(Wall([pos[0], pos[1]], [pos[0] + size[0], pos[1]], 1, -1)) 534 | walls.append(Wall([pos[0], pos[1]], [pos[0], pos[1] + size[1]], 0, -1)) 535 | walls.append(Wall([pos[0] + size[0], pos[1]], [pos[0] + size[0], pos[1] + size[1]], 0, 1)) 536 | walls.append(Wall([pos[0], pos[1] + size[1]], [pos[0] + size[0], pos[1] + size[1]], 1, 1)) 537 | return walls 538 | 539 | 540 | def point_str(point) -> str: 541 | """ 542 | Convert a point to a string 543 | """ 544 | # some string conversion functions (since looking up strings in a dict is pretty fast performance-wise) 545 | return str(point[0]) + ";" + str(point[1]) 546 | 547 | 548 | def line_str(line, point) -> str: 549 | """ 550 | Convert a line to a string 551 | """ 552 | return point_str(line[point]) + ";" + str(line[2][0]) + ";" + str(line[2][1]) 553 | 554 | 555 | def str_point(string: str): 556 | """ 557 | Convert string to point 558 | """ 559 | return [int(v) for v in string.split(";")[:2]] 560 | 561 | 562 | def set_mask_alpha(surf: pygame.Surface, alpha: int) -> pygame.Surface: 563 | """ 564 | Set the alpha of the screen mask 565 | """ 566 | return mult_color(surf, (alpha, alpha, alpha)) 567 | 568 | 569 | def mult_color(surf: pygame.Surface, color: Tuple[int, int, int]) -> pygame.Surface: 570 | """ 571 | Multiply the color given on the provided surface. 572 | """ 573 | mult_surf = surf.copy() 574 | mult_surf.fill(color) 575 | new_surf = surf.copy() 576 | new_surf.blit(mult_surf, (0, 0), special_flags=BLEND_RGBA_MULT) 577 | return new_surf 578 | 579 | 580 | def get_chunk(point, chunk_size): 581 | return [point[0] // chunk_size, point[1] // chunk_size] 582 | 583 | 584 | def generate_walls(light_box: LightBox, map_data: List[List[int]], tile_size: int) -> List[Wall]: 585 | """ 586 | Adds walls to the designated light box using a list of "air" (empty) tiles. 587 | 588 | Bordering sides will be joined together to reduce the wall count. The tile locations in the map_data should be 589 | the grid positions. The positions are then multiplied by the tile_size to get the pixel positions of the 590 | tiles along with the coordinates of the sides. The returned data is just a list of Wall objects that were 591 | added to the given LightBox. 592 | """ 593 | # looking up a string in a dict is significantly quicker than looking up in a list 594 | map_dict = {} 595 | lines = [] 596 | 597 | # generate a dict with all of the tiles 598 | for tile in map_data: 599 | map_dict[str(tile[0]) + ";" + str(tile[1])] = 1 600 | 601 | # add all the walls by checking air tiles for bordering solid tiles (solid tiles are where there are no tiles in 602 | # the dict) 603 | for air_tile in map_data: 604 | # check all sides for each air tile 605 | if point_str([air_tile[0] + 1, air_tile[1]]) not in map_dict: 606 | # generate line in [p1, p2, [vertical, inside/outside]] format 607 | lines.append( 608 | [ 609 | [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size], 610 | [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size + tile_size], 611 | [0, -1], 612 | ] 613 | ) 614 | if point_str([air_tile[0] - 1, air_tile[1]]) not in map_dict: 615 | lines.append( 616 | [ 617 | [air_tile[0] * tile_size, air_tile[1] * tile_size], 618 | [air_tile[0] * tile_size, air_tile[1] * tile_size + tile_size], 619 | [0, 1], 620 | ] 621 | ) 622 | if point_str([air_tile[0], air_tile[1] + 1]) not in map_dict: 623 | lines.append( 624 | [ 625 | [air_tile[0] * tile_size, air_tile[1] * tile_size + tile_size], 626 | [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size + tile_size], 627 | [1, -1], 628 | ] 629 | ) 630 | if point_str([air_tile[0], air_tile[1] - 1]) not in map_dict: 631 | lines.append( 632 | [ 633 | [air_tile[0] * tile_size, air_tile[1] * tile_size], 634 | [air_tile[0] * tile_size + tile_size, air_tile[1] * tile_size], 635 | [1, 1], 636 | ] 637 | ) 638 | 639 | # reformat the data into a useful form for the geometry tricks later 640 | # this adds each endpoint to a dict as a key with the associated endpoint being in the list of associated values 641 | # (so 1 point can link to 2 bordering points where lines are connected) 642 | # it keys with respect to the vertical/horizontal aspect and the inward/outward aspect, so all lines that use the 643 | # same keys are part of a single joined line 644 | line_dict: Dict[str, List[str]] = {} 645 | for line in lines: 646 | for i in range(2): 647 | if line_str(line, i) in line_dict: 648 | line_dict[line_str(line, i)].append(line_str(line, 1 - i)) 649 | else: 650 | line_dict[line_str(line, i)] = [line_str(line, 1 - i)] 651 | 652 | final_walls = [] 653 | # keep track of the processed points so that those keys can be ignored (we add 4 points per line since each point 654 | # must be a key once and a value once) 655 | processed_points = [] 656 | for point in line_dict: 657 | # the length of the items in this dict are the number of connected points 658 | # so if there's only 1 connected point, that means it's the end of a line 659 | # we can then follow the line's points to calculate the single line based off the connections 660 | if point not in processed_points: 661 | # look for the end of the line and skip all the others (since anything else must be connected to an end 662 | # due to the respect to direction) 663 | if len(line_dict[point]) == 1: 664 | # add this point to the list to ignore 665 | processed_points.append(point) 666 | offset = 1 667 | p1 = str_point(point) 668 | p2 = str_point(line_dict[point][0]) 669 | # calculate the direction based on the 2 points 670 | direction = [(p2[0] - p1[0]) // tile_size, (p2[1] - p1[1]) // tile_size] 671 | # loop through the connected points until the other end is found 672 | while 1: 673 | # generate the string for the next point 674 | target_pos = ( 675 | str(p1[0] + direction[0] * offset * tile_size) 676 | + ";" 677 | + str(p1[1] + direction[1] * offset * tile_size) 678 | + ";" 679 | + point.split(";")[2] 680 | + ";" 681 | + point.split(";")[3] 682 | ) 683 | # when the connected point only links to 1 point, you've found the other end of the line 684 | processed_points.append(target_pos) 685 | if len(line_dict[target_pos]) == 1: 686 | break 687 | offset += 1 688 | # append to the walls list based on the last point found and the starting point 689 | final_walls.append([p1, str_point(target_pos), int(point.split(";")[2]), int(point.split(";")[3])]) 690 | 691 | # correct overshot edges (must be done after grouping for proper results) and generate Wall objects 692 | for wall in final_walls: 693 | grid_pos_x = wall[0][0] 694 | grid_pos_y = wall[0][1] 695 | 696 | # get tile location of wall 697 | tile_x = int(grid_pos_x // tile_size) 698 | tile_y = int(grid_pos_y // tile_size) 699 | 700 | # check for relevant bordering tiles to determine if it's okay to shorten the wall 701 | if not wall[2]: 702 | if wall[3] == 1: 703 | if [tile_x, tile_y + 1] in map_data: 704 | wall[1][1] -= 1 705 | else: 706 | if [tile_x - 1, tile_y + 1] in map_data: 707 | wall[1][1] -= 1 708 | 709 | # move right-facing wall inward 710 | if wall[3] == 1: 711 | wall[0][0] -= 1 712 | wall[1][0] -= 1 713 | else: 714 | if wall[3] == 1: 715 | if [tile_x + 1, tile_y] in map_data: 716 | wall[1][0] -= 1 717 | else: 718 | if [tile_x + 1, tile_y - 1] in map_data: 719 | wall[1][0] -= 1 720 | 721 | # move downward-facing wall inward 722 | if wall[3] == 1: 723 | wall[0][1] -= 1 724 | wall[1][1] -= 1 725 | 726 | # generate Wall objects 727 | _final_walls = [Wall(*wall) for wall in final_walls] 728 | 729 | # apply walls 730 | light_box.add_walls(_final_walls) 731 | 732 | # return the list just in case it's needed for something 733 | return _final_walls 734 | --------------------------------------------------------------------------------