├── .gitignore ├── deathvalley_panorama.jpg ├── knife_hand.png ├── raycast.py ├── raycast_vary_height.py ├── readme.md └── wall_texture.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | Thumbs.db 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | dist 12 | build 13 | eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | -------------------------------------------------------------------------------- /deathvalley_panorama.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mekire/pygame-raycasting-experiment/e6ab901914f1240bf63c120cb43ce25e0e03e2e3/deathvalley_panorama.jpg -------------------------------------------------------------------------------- /knife_hand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mekire/pygame-raycasting-experiment/e6ab901914f1240bf63c120cb43ce25e0e03e2e3/knife_hand.png -------------------------------------------------------------------------------- /raycast.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import math 4 | import random 5 | import itertools 6 | import pygame as pg 7 | 8 | from collections import namedtuple 9 | 10 | 11 | if sys.version_info[0] == 2: 12 | range = xrange 13 | 14 | 15 | CAPTION = "Ray-Casting with Python" 16 | SCREEN_SIZE = (1200, 600) 17 | CIRCLE = 2*math.pi 18 | SCALE = (SCREEN_SIZE[0]+SCREEN_SIZE[1])/1200.0 19 | FIELD_OF_VIEW = math.pi*0.4 20 | NO_WALL = float("inf") 21 | RAIN_COLOR = (255, 255, 255, 40) 22 | 23 | 24 | # Semantically meaningful tuples for use in GameMap and Camera class. 25 | RayInfo = namedtuple("RayInfo", ["sin", "cos"]) 26 | WallInfo = namedtuple("WallInfo", ["top", "height"]) 27 | 28 | 29 | class Image(object): 30 | """A very basic class that couples an image with its dimensions""" 31 | def __init__(self, image): 32 | """ 33 | The image argument is a preloaded and converted pg.Surface object. 34 | """ 35 | self.image = image 36 | self.width, self.height = self.image.get_size() 37 | 38 | 39 | class Player(object): 40 | """Handles the player's position, rotation, and control.""" 41 | def __init__(self, x, y, direction): 42 | """ 43 | The arguments x and y are floating points. Anything between zero 44 | and the game map size is on our generated map. 45 | Choosing a point outside this range ensures our player doesn't spawn 46 | inside a wall. The direction argument is the initial angle (given in 47 | radians) of the player. 48 | """ 49 | self.x = x 50 | self.y = y 51 | self.direction = direction 52 | self.speed = 3 # Map cells per second. 53 | self.rotate_speed = CIRCLE/2 # 180 degrees in a second. 54 | self.weapon = Image(IMAGES["knife"]) 55 | self.paces = 0 # Used for weapon placement. 56 | 57 | def rotate(self, angle): 58 | """Change the player's direction when appropriate key is pressed.""" 59 | self.direction = (self.direction+angle+CIRCLE)%CIRCLE 60 | 61 | def walk(self, distance, game_map): 62 | """ 63 | Calculate the player's next position, and move if he will 64 | not end up inside a wall. 65 | """ 66 | dx = math.cos(self.direction)*distance 67 | dy = math.sin(self.direction)*distance 68 | if game_map.get(self.x+dx, self.y) <= 0: 69 | self.x += dx 70 | if game_map.get(self.x, self.y+dy) <= 0: 71 | self.y += dy 72 | self.paces += distance 73 | 74 | def update(self, keys, dt, game_map): 75 | """Execute movement functions if the appropriate key is pressed.""" 76 | if keys[pg.K_LEFT]: 77 | self.rotate(-self.rotate_speed*dt) 78 | if keys[pg.K_RIGHT]: 79 | self.rotate(self.rotate_speed*dt) 80 | if keys[pg.K_UP]: 81 | self.walk(self.speed*dt, game_map) 82 | if keys[pg.K_DOWN]: 83 | self.walk(-self.speed*dt, game_map) 84 | 85 | 86 | class GameMap(object): 87 | """ 88 | A class to generate a random map for us; handle ray casting; 89 | and provide a method of detecting colissions. 90 | """ 91 | def __init__(self, size): 92 | """ 93 | The size argument is an integer which tells us the width and height 94 | of our game grid. For example, a size of 32 will create a 32x32 map. 95 | """ 96 | self.size = size 97 | self.wall_grid = self.randomize() 98 | self.sky_box = Image(IMAGES["sky"]) 99 | self.wall_texture = Image(IMAGES["texture"]) 100 | self.light = 0 101 | 102 | def get(self, x, y): 103 | """A method to check if a given coordinate is colliding with a wall.""" 104 | point = (int(math.floor(x)), int(math.floor(y))) 105 | return self.wall_grid.get(point, -1) 106 | 107 | def randomize(self): 108 | """ 109 | Generate our map randomly. In the code below their is a 30% chance 110 | of a cell containing a wall. 111 | """ 112 | coordinates = itertools.product(range(self.size), repeat=2) 113 | return {coord : random.random()<0.3 for coord in coordinates} 114 | 115 | def cast_ray(self, point, angle, cast_range): 116 | """ 117 | The meat of our ray casting program. Given a point, 118 | an angle (in radians), and a maximum cast range, check if any 119 | collisions with the ray occur. Casting will stop if a collision is 120 | detected (cell with greater than 0 height), or our maximum casting 121 | range is exceeded without detecting anything. 122 | """ 123 | info = RayInfo(math.sin(angle), math.cos(angle)) 124 | origin = Point(point) 125 | ray = [origin] 126 | while origin.height <= 0 and origin.distance <= cast_range: 127 | dist = origin.distance 128 | step_x = origin.step(info.sin, info.cos) 129 | step_y = origin.step(info.cos, info.sin, invert=True) 130 | if step_x.length < step_y.length: 131 | next_step = step_x.inspect(info, self, 1, 0, dist, step_x.y) 132 | else: 133 | next_step = step_y.inspect(info, self, 0, 1, dist, step_y.x) 134 | ray.append(next_step) 135 | origin = next_step 136 | return ray 137 | 138 | def update(self, dt): 139 | """Adjust ambient lighting based on time.""" 140 | if self.light > 0: 141 | self.light = max(self.light-10*dt, 0) 142 | elif random.random()*5 < dt: 143 | self.light = 2 144 | 145 | 146 | class Point(object): 147 | """ 148 | A fairly basic class to assist us with ray casting. The return value of 149 | the GameMap.cast_ray() method is a list of Point instances. 150 | """ 151 | def __init__(self, point, length=None): 152 | self.x = point[0] 153 | self.y = point[1] 154 | self.height = 0 155 | self.distance = 0 156 | self.shading = None 157 | self.length = length 158 | 159 | def step(self, rise, run, invert=False): 160 | """ 161 | Return a new Point advanced one step from the caller. If run is 162 | zero, the length of the new Point will be infinite. 163 | """ 164 | try: 165 | x, y = (self.y,self.x) if invert else (self.x,self.y) 166 | dx = math.floor(x+1)-x if run > 0 else math.ceil(x-1)-x 167 | dy = dx*(rise/run) 168 | next_x = y+dy if invert else x+dx 169 | next_y = x+dx if invert else y+dy 170 | length = math.hypot(dx, dy) 171 | except ZeroDivisionError: 172 | next_x = next_y = None 173 | length = NO_WALL 174 | return Point((next_x,next_y), length) 175 | 176 | def inspect(self, info, game_map, shift_x, shift_y, distance, offset): 177 | """ 178 | Ran when the step is selected as the next in the ray. 179 | Sets the steps self.height, self.distance, and self.shading, 180 | to the required values. 181 | """ 182 | dx = shift_x if info.cos<0 else 0 183 | dy = shift_y if info.sin<0 else 0 184 | self.height = game_map.get(self.x-dx, self.y-dy) 185 | self.distance = distance+self.length 186 | if shift_x: 187 | self.shading = 2 if info.cos<0 else 0 188 | else: 189 | self.shading = 2 if info.sin<0 else 1 190 | self.offset = offset-math.floor(offset) 191 | return self 192 | 193 | 194 | class Camera(object): 195 | """Handles the projection and rendering of all objects on the screen.""" 196 | def __init__(self, screen, resolution): 197 | self.screen = screen 198 | self.width, self.height = self.screen.get_size() 199 | self.resolution = float(resolution) 200 | self.spacing = self.width/resolution 201 | self.field_of_view = FIELD_OF_VIEW 202 | self.range = 8 203 | self.light_range = 5 204 | self.scale = SCALE 205 | self.flash = pg.Surface((self.width, self.height//2)).convert_alpha() 206 | 207 | def render(self, player, game_map): 208 | """Render everything in order.""" 209 | self.draw_sky(player.direction, game_map.sky_box, game_map.light) 210 | self.draw_columns(player, game_map) 211 | self.draw_weapon(player.weapon, player.paces) 212 | 213 | def draw_sky(self, direction, sky, ambient_light): 214 | """ 215 | Calculate the skies offset so that it wraps, and draw. 216 | If the ambient light is greater than zero, draw lightning flash. 217 | """ 218 | left = -sky.width*direction/CIRCLE 219 | self.screen.blit(sky.image, (left,0)) 220 | if left 0: 223 | alpha = 255*min(1, ambient_light*0.1) 224 | self.flash.fill((255,255,255,alpha)) 225 | self.screen.blit(self.flash, (0, self.height//2)) 226 | 227 | def draw_columns(self, player, game_map): 228 | """ 229 | For every column in the given resolution, cast a ray, and render that 230 | column. 231 | """ 232 | for column in range(int(self.resolution)): 233 | angle = self.field_of_view*(column/self.resolution-0.5) 234 | point = player.x, player.y 235 | ray = game_map.cast_ray(point, player.direction+angle, self.range) 236 | self.draw_column(column, ray, angle, game_map) 237 | 238 | def draw_column(self, column, ray, angle, game_map): 239 | """ 240 | Examine each step of the ray, starting with the furthest. 241 | If the height is greater than zero, render the column (and shadow). 242 | Rain drops will be drawn for every step. 243 | """ 244 | left = int(math.floor(column*self.spacing)) 245 | for ray_index in range(len(ray)-1, -1, -1): 246 | step = ray[ray_index] 247 | if step.height > 0: 248 | texture = game_map.wall_texture 249 | width = int(math.ceil(self.spacing)) 250 | texture_x = int(texture.width*step.offset) ### 251 | wall = self.project(step.height, angle, step.distance) 252 | image_location = pg.Rect(texture_x, 0, 1, texture.height) 253 | image_slice = texture.image.subsurface(image_location) 254 | scale_rect = pg.Rect(left, wall.top, width, wall.height) 255 | scaled = pg.transform.scale(image_slice, scale_rect.size) 256 | self.screen.blit(scaled, scale_rect) 257 | self.draw_shadow(step, scale_rect, game_map.light) 258 | self.draw_rain(step, angle, left, ray_index) 259 | 260 | def draw_shadow(self, step, scale_rect, light): 261 | """ 262 | Render the shadow on a column with regards to its distance and 263 | shading attribute. 264 | """ 265 | shade_value = step.distance+step.shading 266 | max_light = shade_value/float(self.light_range)-light 267 | alpha = 255*min(1, max(max_light, 0)) 268 | shade_slice = pg.Surface(scale_rect.size).convert_alpha() 269 | shade_slice.fill((0,0,0,alpha)) 270 | self.screen.blit(shade_slice, scale_rect) 271 | 272 | def draw_rain(self, step, angle, left, ray_index): 273 | """ 274 | Render a number of rain drops to add depth to our scene and mask 275 | roughness. 276 | """ 277 | rain_drops = int(random.random()**3*ray_index) 278 | if rain_drops: 279 | rain = self.project(0.1, angle, step.distance) 280 | drop = pg.Surface((1,rain.height)).convert_alpha() 281 | drop.fill(RAIN_COLOR) 282 | for _ in range(rain_drops): 283 | self.screen.blit(drop, (left, random.random()*rain.top)) 284 | 285 | def draw_weapon(self, weapon, paces): 286 | """ 287 | Calulate new weapon position based on player's pace attribute, 288 | and render. 289 | """ 290 | bob_x = math.cos(paces*2)*self.scale*6 291 | bob_y = math.sin(paces*4)*self.scale*6 292 | left = self.width*0.66+bob_x 293 | top = self.height*0.6+bob_y 294 | self.screen.blit(weapon.image, (left, top)) 295 | 296 | def project(self, height, angle, distance): 297 | """ 298 | Find the position on the screen after perspective projection. 299 | A minimum value is used for z to prevent slices blowing up to 300 | unmanageable sizes when the player is very close. 301 | """ 302 | z = max(distance*math.cos(angle),0.2) 303 | wall_height = self.height*height/float(z) 304 | bottom = self.height/float(2)*(1+1/float(z)) 305 | return WallInfo(bottom-wall_height, int(wall_height)) 306 | 307 | 308 | class Control(object): 309 | """ 310 | The core of our program. Responsible for running our main loop; 311 | processing events; updating; and rendering. 312 | """ 313 | def __init__(self): 314 | self.screen = pg.display.get_surface() 315 | self.clock = pg.time.Clock() 316 | self.fps = 60.0 317 | self.keys = pg.key.get_pressed() 318 | self.done = False 319 | self.player = Player(15.3, -1.2, math.pi*0.3) 320 | self.game_map = GameMap(32) 321 | self.camera = Camera(self.screen, 300) 322 | 323 | def event_loop(self): 324 | """ 325 | Quit game on a quit event and update self.keys on any keyup or keydown. 326 | """ 327 | for event in pg.event.get(): 328 | if event.type == pg.QUIT: 329 | self.done = True 330 | elif event.type in (pg.KEYDOWN, pg.KEYUP): 331 | self.keys = pg.key.get_pressed() 332 | 333 | def update(self, dt): 334 | """Update the game_map and player.""" 335 | self.game_map.update(dt) 336 | self.player.update(self.keys, dt, self.game_map) 337 | 338 | def display_fps(self): 339 | """Show the program's FPS in the window handle.""" 340 | caption = "{} - FPS: {:.2f}".format(CAPTION, self.clock.get_fps()) 341 | pg.display.set_caption(caption) 342 | 343 | def main_loop(self): 344 | """Process events, update, and render.""" 345 | dt = self.clock.tick(self.fps)/1000.0 346 | while not self.done: 347 | self.event_loop() 348 | self.update(dt) 349 | self.camera.render(self.player, self.game_map) 350 | dt = self.clock.tick(self.fps)/1000.0 351 | pg.display.update() 352 | self.display_fps() 353 | 354 | 355 | def load_resources(): 356 | """ 357 | Return a dictionary of our needed images; loaded, converted, and scaled. 358 | """ 359 | images = {} 360 | knife_image = pg.image.load("knife_hand.png").convert_alpha() 361 | knife_w, knife_h = knife_image.get_size() 362 | knife_scale = (int(knife_w*SCALE), int(knife_h*SCALE)) 363 | images["knife"] = pg.transform.smoothscale(knife_image, knife_scale) 364 | images["texture"] = pg.image.load("wall_texture.jpg").convert() 365 | sky_size = int(SCREEN_SIZE[0]*(CIRCLE/FIELD_OF_VIEW)), SCREEN_SIZE[1] 366 | sky_box_image = pg.image.load("deathvalley_panorama.jpg").convert() 367 | images["sky"] = pg.transform.smoothscale(sky_box_image, sky_size) 368 | return images 369 | 370 | 371 | def main(): 372 | """Prepare the display, load images, and get our programming running.""" 373 | global IMAGES 374 | os.environ["SDL_VIDEO_CENTERED"] = "True" 375 | pg.init() 376 | pg.display.set_mode(SCREEN_SIZE) 377 | IMAGES = load_resources() 378 | Control().main_loop() 379 | pg.quit() 380 | sys.exit() 381 | 382 | 383 | if __name__ == "__main__": 384 | main() 385 | -------------------------------------------------------------------------------- /raycast_vary_height.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example allows blocks to have different heights. 3 | It runs much worse than raycast.py because all rays must be cast 4 | all the way out to the maximum range. 5 | """ 6 | 7 | import os 8 | import sys 9 | import math 10 | import random 11 | import itertools 12 | import pygame as pg 13 | 14 | from collections import namedtuple 15 | 16 | 17 | if sys.version_info[0] == 2: 18 | range = xrange 19 | 20 | 21 | CAPTION = "Ray-Casting with Python - Varying Heights" 22 | SCREEN_SIZE = (1200, 600) 23 | CIRCLE = 2*math.pi 24 | SCALE = (SCREEN_SIZE[0]+SCREEN_SIZE[1])/1200.0 25 | FIELD_OF_VIEW = math.pi*0.4 26 | NO_WALL = float("inf") 27 | RAIN_COLOR = (255, 255, 255, 40) 28 | 29 | 30 | # Semantically meaningful tuples for use in GameMap and Camera class. 31 | RayInfo = namedtuple("RayInfo", ["sin", "cos"]) 32 | WallInfo = namedtuple("WallInfo", ["top", "height"]) 33 | 34 | 35 | class Image(object): 36 | """A very basic class that couples an image with its dimensions""" 37 | def __init__(self, image): 38 | """ 39 | The image argument is a preloaded and converted pg.Surface object. 40 | """ 41 | self.image = image 42 | self.width, self.height = self.image.get_size() 43 | 44 | 45 | class Player(object): 46 | """Handles the player's position, rotation, and control.""" 47 | def __init__(self, x, y, direction): 48 | """ 49 | The arguments x and y are floating points. Anything between zero 50 | and the game map size is on our generated map. 51 | Choosing a point outside this range ensures our player doesn't spawn 52 | inside a wall. The direction argument is the initial angle (given in 53 | radians) of the player. 54 | """ 55 | self.x = x 56 | self.y = y 57 | self.direction = direction 58 | self.speed = 3 # Map cells per second. 59 | self.rotate_speed = CIRCLE/2 # 180 degrees in a second. 60 | self.weapon = Image(IMAGES["knife"]) 61 | self.paces = 0 # Used for weapon placement. 62 | 63 | def rotate(self, angle): 64 | """Change the player's direction when appropriate key is pressed.""" 65 | self.direction = (self.direction+angle+CIRCLE)%CIRCLE 66 | 67 | def walk(self, distance, game_map): 68 | """ 69 | Calculate the player's next position, and move if he will 70 | not end up inside a wall. 71 | """ 72 | dx = math.cos(self.direction)*distance 73 | dy = math.sin(self.direction)*distance 74 | if game_map.get(self.x+dx, self.y) <= 0: 75 | self.x += dx 76 | if game_map.get(self.x, self.y+dy) <= 0: 77 | self.y += dy 78 | self.paces += distance 79 | 80 | def update(self, keys, dt, game_map): 81 | """Execute movement functions if the appropriate key is pressed.""" 82 | if keys[pg.K_LEFT]: 83 | self.rotate(-self.rotate_speed*dt) 84 | if keys[pg.K_RIGHT]: 85 | self.rotate(self.rotate_speed*dt) 86 | if keys[pg.K_UP]: 87 | self.walk(self.speed*dt, game_map) 88 | if keys[pg.K_DOWN]: 89 | self.walk(-self.speed*dt, game_map) 90 | 91 | 92 | class GameMap(object): 93 | """ 94 | A class to generate a random map for us; handle ray casting; 95 | and provide a method of detecting colissions. 96 | """ 97 | def __init__(self, size): 98 | """ 99 | The size argument is an integer which tells us the width and height 100 | of our game grid. For example, a size of 32 will create a 32x32 map. 101 | """ 102 | self.size = size 103 | self.wall_grid = self.randomize() 104 | self.sky_box = Image(IMAGES["sky"]) 105 | self.wall_texture = Image(IMAGES["texture"]) 106 | self.light = 0 107 | 108 | def get(self, x, y): 109 | """A method to check if a given coordinate is colliding with a wall.""" 110 | point = (int(math.floor(x)), int(math.floor(y))) 111 | return self.wall_grid.get(point, -1) 112 | 113 | def randomize(self): 114 | """ 115 | Generate our map randomly. In the code below their is a 30% chance 116 | of a cell containing a wall. 117 | """ 118 | game_map = {} 119 | for coord in itertools.product(range(self.size), repeat=2): 120 | if random.random()<0.3: 121 | game_map[coord] = random.choice((0.6, 1, 1.5)) 122 | return game_map 123 | 124 | def cast_ray(self, point, angle, cast_range): 125 | """ 126 | The meat of our ray casting program. Given a point, 127 | an angle (in radians), and a maximum cast range, check if any 128 | collisions with the ray occur. 129 | """ 130 | info = RayInfo(math.sin(angle), math.cos(angle)) 131 | origin = Point(point) 132 | ray = [origin] 133 | while origin.distance <= cast_range: 134 | dist = origin.distance 135 | step_x = origin.step(info.sin, info.cos) 136 | step_y = origin.step(info.cos, info.sin, invert=True) 137 | if step_x.length < step_y.length: 138 | next_step = step_x.inspect(info, self, 1, 0, dist, step_x.y) 139 | else: 140 | next_step = step_y.inspect(info, self, 0, 1, dist, step_y.x) 141 | ray.append(next_step) 142 | origin = next_step 143 | return ray 144 | 145 | def update(self, dt): 146 | """Adjust ambient lighting based on time.""" 147 | if self.light > 0: 148 | self.light = max(self.light-10*dt, 0) 149 | elif random.random()*5 < dt: 150 | self.light = 2 151 | 152 | 153 | class Point(object): 154 | """ 155 | A fairly basic class to assist us with ray casting. The return value of 156 | the GameMap.cast_ray() method is a list of Point instances. 157 | """ 158 | def __init__(self, point, length=None): 159 | self.x = point[0] 160 | self.y = point[1] 161 | self.height = 0 162 | self.distance = 0 163 | self.shading = None 164 | self.length = length 165 | 166 | def step(self, rise, run, invert=False): 167 | """ 168 | Return a new Point advanced one step from the caller. If run is 169 | zero, the length of the new Point will be infinite. 170 | """ 171 | try: 172 | x, y = (self.y,self.x) if invert else (self.x,self.y) 173 | dx = math.floor(x+1)-x if run > 0 else math.ceil(x-1)-x 174 | dy = dx*(rise/run) 175 | next_x = y+dy if invert else x+dx 176 | next_y = x+dx if invert else y+dy 177 | length = math.hypot(dx, dy) 178 | except ZeroDivisionError: 179 | next_x = next_y = None 180 | length = NO_WALL 181 | return Point((next_x,next_y), length) 182 | 183 | def inspect(self, info, game_map, shift_x, shift_y, distance, offset): 184 | """ 185 | Ran when the step is selected as the next in the ray. 186 | Sets the steps self.height, self.distance, and self.shading, 187 | to the required values. 188 | """ 189 | dx = shift_x if info.cos<0 else 0 190 | dy = shift_y if info.sin<0 else 0 191 | self.height = game_map.get(self.x-dx, self.y-dy) 192 | self.distance = distance+self.length 193 | if shift_x: 194 | self.shading = 2 if info.cos<0 else 0 195 | else: 196 | self.shading = 2 if info.sin<0 else 1 197 | self.offset = offset-math.floor(offset) 198 | return self 199 | 200 | 201 | class Camera(object): 202 | """Handles the projection and rendering of all objects on the screen.""" 203 | def __init__(self, screen, resolution): 204 | self.screen = screen 205 | self.width, self.height = self.screen.get_size() 206 | self.resolution = float(resolution) 207 | self.spacing = self.width/resolution 208 | self.field_of_view = FIELD_OF_VIEW 209 | self.range = 8 210 | self.light_range = 5 211 | self.scale = SCALE 212 | self.flash = pg.Surface((self.width, self.height//2)).convert_alpha() 213 | 214 | def render(self, player, game_map): 215 | """Render everything in order.""" 216 | self.draw_sky(player.direction, game_map.sky_box, game_map.light) 217 | self.draw_columns(player, game_map) 218 | self.draw_weapon(player.weapon, player.paces) 219 | 220 | def draw_sky(self, direction, sky, ambient_light): 221 | """ 222 | Calculate the skies offset so that it wraps, and draw. 223 | If the ambient light is greater than zero, draw lightning flash. 224 | """ 225 | left = -sky.width*direction/CIRCLE 226 | self.screen.blit(sky.image, (left,0)) 227 | if left 0: 230 | alpha = 255*min(1, ambient_light*0.1) 231 | self.flash.fill((255,255,255,alpha)) 232 | self.screen.blit(self.flash, (0, self.height//2)) 233 | 234 | def draw_columns(self, player, game_map): 235 | """ 236 | For every column in the given resolution, cast a ray, and render that 237 | column. 238 | """ 239 | for column in range(int(self.resolution)): 240 | angle = self.field_of_view*(column/self.resolution-0.5) 241 | point = player.x, player.y 242 | ray = game_map.cast_ray(point, player.direction+angle, self.range) 243 | self.draw_column(column, ray, angle, game_map) 244 | 245 | def draw_column(self, column, ray, angle, game_map): 246 | """ 247 | Examine each step of the ray, starting with the furthest. 248 | If the height is greater than zero, render the column (and shadow). 249 | Rain drops will be drawn for every step. 250 | """ 251 | left = int(math.floor(column*self.spacing)) 252 | for ray_index in range(len(ray)-1, -1, -1): 253 | step = ray[ray_index] 254 | if step.height > 0: 255 | texture = game_map.wall_texture 256 | width = int(math.ceil(self.spacing)) 257 | texture_x = int(texture.width*step.offset) ### 258 | wall = self.project(step.height, angle, step.distance) 259 | image_location = pg.Rect(texture_x, 0, 1, texture.height) 260 | image_slice = texture.image.subsurface(image_location) 261 | scale_rect = pg.Rect(left, wall.top, width, wall.height) 262 | scaled = pg.transform.scale(image_slice, scale_rect.size) 263 | self.screen.blit(scaled, scale_rect) 264 | self.draw_shadow(step, scale_rect, game_map.light) 265 | self.draw_rain(step, angle, left, ray_index) 266 | 267 | def draw_shadow(self, step, scale_rect, light): 268 | """ 269 | Render the shadow on a column with regards to its distance and 270 | shading attribute. 271 | """ 272 | shade_value = step.distance+step.shading 273 | max_light = shade_value/float(self.light_range)-light 274 | alpha = 255*min(1, max(max_light, 0)) 275 | shade_slice = pg.Surface(scale_rect.size).convert_alpha() 276 | shade_slice.fill((0,0,0,alpha)) 277 | self.screen.blit(shade_slice, scale_rect) 278 | 279 | def draw_rain(self, step, angle, left, ray_index): 280 | """ 281 | Render a number of rain drops to add depth to our scene and mask 282 | roughness. 283 | """ 284 | rain_drops = int(random.random()**3*ray_index) 285 | if rain_drops: 286 | rain = self.project(0.1, angle, step.distance) 287 | drop = pg.Surface((1,rain.height)).convert_alpha() 288 | drop.fill(RAIN_COLOR) 289 | for _ in range(rain_drops): 290 | self.screen.blit(drop, (left, random.random()*rain.top)) 291 | 292 | def draw_weapon(self, weapon, paces): 293 | """ 294 | Calulate new weapon position based on player's pace attribute, 295 | and render. 296 | """ 297 | bob_x = math.cos(paces*2)*self.scale*6 298 | bob_y = math.sin(paces*4)*self.scale*6 299 | left = self.width*0.66+bob_x 300 | top = self.height*0.6+bob_y 301 | self.screen.blit(weapon.image, (left, top)) 302 | 303 | def project(self, height, angle, distance): 304 | """ 305 | Find the position on the screen after perspective projection. 306 | A minimum value is used for z to prevent slices blowing up to 307 | unmanageable sizes when the player is very close. 308 | """ 309 | z = max(distance*math.cos(angle),0.2) 310 | wall_height = self.height*height/float(z) 311 | bottom = self.height/float(2)*(1+1/float(z)) 312 | return WallInfo(bottom-wall_height, int(wall_height)) 313 | 314 | 315 | class Control(object): 316 | """ 317 | The core of our program. Responsible for running our main loop; 318 | processing events; updating; and rendering. 319 | """ 320 | def __init__(self): 321 | self.screen = pg.display.get_surface() 322 | self.clock = pg.time.Clock() 323 | self.fps = 60.0 324 | self.keys = pg.key.get_pressed() 325 | self.done = False 326 | self.player = Player(15.3, -1.2, math.pi*0.3) 327 | self.game_map = GameMap(32) 328 | self.camera = Camera(self.screen, 300) 329 | 330 | def event_loop(self): 331 | """ 332 | Quit game on a quit event and update self.keys on any keyup or keydown. 333 | """ 334 | for event in pg.event.get(): 335 | if event.type == pg.QUIT: 336 | self.done = True 337 | elif event.type in (pg.KEYDOWN, pg.KEYUP): 338 | self.keys = pg.key.get_pressed() 339 | 340 | def update(self, dt): 341 | """Update the game_map and player.""" 342 | self.game_map.update(dt) 343 | self.player.update(self.keys, dt, self.game_map) 344 | 345 | def display_fps(self): 346 | """Show the program's FPS in the window handle.""" 347 | caption = "{} - FPS: {:.2f}".format(CAPTION, self.clock.get_fps()) 348 | pg.display.set_caption(caption) 349 | 350 | def main_loop(self): 351 | """Process events, update, and render.""" 352 | dt = self.clock.tick(self.fps)/1000.0 353 | while not self.done: 354 | self.event_loop() 355 | self.update(dt) 356 | self.camera.render(self.player, self.game_map) 357 | dt = self.clock.tick(self.fps)/1000.0 358 | pg.display.update() 359 | self.display_fps() 360 | 361 | 362 | def load_resources(): 363 | """ 364 | Return a dictionary of our needed images; loaded, converted, and scaled. 365 | """ 366 | images = {} 367 | knife_image = pg.image.load("knife_hand.png").convert_alpha() 368 | knife_w, knife_h = knife_image.get_size() 369 | knife_scale = (int(knife_w*SCALE), int(knife_h*SCALE)) 370 | images["knife"] = pg.transform.smoothscale(knife_image, knife_scale) 371 | images["texture"] = pg.image.load("wall_texture.jpg").convert() 372 | sky_size = int(SCREEN_SIZE[0]*(CIRCLE/FIELD_OF_VIEW)), SCREEN_SIZE[1] 373 | sky_box_image = pg.image.load("deathvalley_panorama.jpg").convert() 374 | images["sky"] = pg.transform.smoothscale(sky_box_image, sky_size) 375 | return images 376 | 377 | 378 | def main(): 379 | """Prepare the display, load images, and get our programming running.""" 380 | global IMAGES 381 | os.environ["SDL_VIDEO_CENTERED"] = "True" 382 | pg.init() 383 | pg.display.set_mode(SCREEN_SIZE) 384 | IMAGES = load_resources() 385 | Control().main_loop() 386 | pg.quit() 387 | sys.exit() 388 | 389 | 390 | if __name__ == "__main__": 391 | main() 392 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | This is an attempt to convert the javascript ray casting example found here: 2 | 3 | http://www.playfuljs.com/a-first-person-engine-in-265-lines/ 4 | 5 | At the moment it is a very direct translation from the javascript, but current results are somewhat... uninspiring. 6 | 7 | ~~I maintain about 8 frames per second. I'm hoping to find some way to combat this.~~ 8 | 9 | Edit: 10 | The frame rate has been brought up to about 20 fps through various simplifications and changes. 11 | Still not amazing, but much better. 12 | 13 | -Mek -------------------------------------------------------------------------------- /wall_texture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mekire/pygame-raycasting-experiment/e6ab901914f1240bf63c120cb43ce25e0e03e2e3/wall_texture.jpg --------------------------------------------------------------------------------