diff --git a/LICENSE b/.github/LICENSE similarity index 100% rename from LICENSE rename to .github/LICENSE diff --git a/README.md b/.github/README.md similarity index 100% rename from README.md rename to .github/README.md diff --git a/main.py b/main.py new file mode 100644 index 0000000..80a397c --- /dev/null +++ b/main.py @@ -0,0 +1,51 @@ +import pygame as pg +import sys +from settings import * +from map import * +from player import * +from raycasting import * +from object_renderer import * + +class Game: + def __init__(self): + pg.init() + pg.mouse.set_visible(False) + self.screen = pg.display.set_mode((WIDTH, HEIGHT)) + self.clock = pg.time.Clock() + self.delta_time = 1 + self.new_game() + + def new_game(self): + self.map = Map(self) + self.player = Player(self) + self.object_renderer = ObjectRenderer(self) + self.raycasting = RayCasting(self) + + def update(self): + self.player.update() + self.raycasting.update() + pg.display.flip() + self.delta_time = self.clock.tick(FPS) + pg.display.set_caption(f"FPS: {self.clock.get_fps() : 0.2f}") + + def draw(self): + #self.screen.fill('black') + self.object_renderer.draw() + #self.map.draw() + #self.player.draw() + + def check_events(self): + for event in pg.event.get(): + if event.type == pg.QUIT or (event.type == pg.KEYDOWN and event.key == pg.K_ESCAPE): + pg.quit() + sys.exit() + + def run(self): + while True: + self.check_events() + self.update() + self.draw() + +if __name__ == '__main__': + game = Game() + game.run() \ No newline at end of file diff --git a/map.py b/map.py new file mode 100644 index 0000000..d5d89c9 --- /dev/null +++ b/map.py @@ -0,0 +1,55 @@ +import pygame as pg + +_ = False +mini_map = [ + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 1], + [1, _, _, 3, 3, 3, 3, _, _, _, 2, 2, 2, _, _, 1], + [1, _, _, _, _, _, 4, _, _, _, _, _, 2, _, _, 1], + [1, _, _, _, _, _, 4, _, _, _, _, _, 2, _, _, 1], + [1, _, _, 3, 3, 3, 3, _, _, _, _, _, _, _, _, 1], + [1, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 1], + [1, _, _, _, 4, _, _, _, 4, _, _, _, _, _, _, 1], + [1, 1, 1, 3, 1, 3, 1, 1, 1, 3, _, _, 3, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 3, _, _, 3, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 3, _, _, 3, 1, 1, 1], + [1, 1, 3, 1, 1, 1, 1, 1, 1, 3, _, _, 3, 1, 1, 1], + [1, 4, _, _, _, _, _, _, _, _, _, _, _, _, _, 1], + [3, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 1], + [1, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 1], + [1, _, _, 2, _, _, _, _, _, 3, 4, _, 4, 3, _, 1], + [1, _, _, 5, _, _, _, _, _, _, 3, _, 3, _, _, 1], + [1, _, _, 2, _, _, _, _, _, _, _, _, _, _, _, 1], + [1, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 1], + [3, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 1], + [1, 4, _, _, _, _, _, _, 4, _, _, 4, _, _, _, 1], + [1, 1, 3, 3, _, _, 3, 3, 1, 3, 3, 1, 3, 1, 1, 1], + [1, 1, 1, 3, _, _, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 3, 3, 4, _, _, 4, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [3, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 3], + [3, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 3], + [3, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 3], + [3, _, _, 5, _, _, _, 5, _, _, _, 5, _, _, _, 3], + [3, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 3], + [3, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 3], + [3, _, _, _, _, _, _, _, _, _, _, _, _, _, _, 3], + [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3], +] + +class Map: + def __init__(self, game): + self.game = game + self.mini_map = mini_map + self.world_map = {} + self.get_map() + + def get_map(self): + for i, row in enumerate(self.mini_map): + for j, tile in enumerate(row): + if tile: + self.world_map[(j, i)] = tile + + def draw(self): + [pg.draw.rect(self.game.screen, 'darkgray', (pos[0] * 100, pos[1] * 100, 100, 100), 1) + for pos in self.world_map] + \ No newline at end of file diff --git a/object_renderer.py b/object_renderer.py new file mode 100644 index 0000000..d39a00c --- /dev/null +++ b/object_renderer.py @@ -0,0 +1,44 @@ +import pygame as pg +from settings import * + +class ObjectRenderer: + def __init__(self, game): + self.game = game + self.screen = self.game.screen + self.wall_textures = self.load_wall_textures() + self.sky_texture = self.get_texture('resources/textures/sky.png', (WIDTH, HALF_HEIGHT)) + self.sky_offset = 0 + + def draw(self): + self.draw_background() + self.render_game_objects() + + def draw_background(self): + # Sky + self.sky_offset = (self.sky_offset + 4.5 * self.game.player.rel) % WIDTH + self.screen.blit(self.sky_texture, (-self.sky_offset, 0)) + self.screen.blit(self.sky_texture, (-self.sky_offset + WIDTH, 0)) + # Ground + pg.draw.rect(self.screen, FLOOR_COLOR, (0, HALF_HEIGHT, WIDTH, HALF_HEIGHT)) + + def render_game_objects(self): + list_objects = self.game.raycasting.objects_to_render + for depth, image, pos in list_objects: + colour = [255 / (1 + depth ** 3.2 * 0.0002)] * 3 + image.fill(colour, special_flags=pg.BLEND_MULT) + self.screen.blit(image, pos) + + @staticmethod + def get_texture(path, res=(TEXTURE_SIZE, TEXTURE_SIZE)): + texture = pg.image.load(path).convert_alpha() + texture = pg.transform.scale(texture, res) + return texture + + def load_wall_textures(self): + return { + 1: self.get_texture('resources/textures/1.png'), + 2: self.get_texture('resources/textures/2.png'), + 3: self.get_texture('resources/textures/3.png'), + 4: self.get_texture('resources/textures/4.jpg'), + 5: self.get_texture('resources/textures/1.jpg'), + } \ No newline at end of file diff --git a/player.py b/player.py new file mode 100644 index 0000000..8c28dc3 --- /dev/null +++ b/player.py @@ -0,0 +1,79 @@ +from settings import * +import pygame as pg +import math + +class Player: + def __init__(self, game): + self.game = game + self.x, self.y = PLAYER_POS + self.rot = PLAYER_ROT + + def movement(self): + # Initialize variables + sin_a = math.sin(self.rot) + cos_a = math.cos(self.rot) + dx, dy = 0, 0 + player_speed = PLAYER_SPEED * self.game.delta_time + player_speed_sin = player_speed * sin_a + player_speed_cos = player_speed * cos_a + + # player movement + keys = pg.key.get_pressed() + if keys[pg.K_w]: + dx += player_speed_cos + dy += player_speed_sin + if keys[pg.K_s]: + dx -= player_speed_cos + dy -= player_speed_sin + if keys[pg.K_a]: + dx += player_speed_sin + dy -= player_speed_cos + if keys[pg.K_d]: + dx -= player_speed_sin + dy += player_speed_cos + + # set player position + self.check_collision(dx, dy) + + # player rotation + if keys[pg.K_LEFT]: + self.rot -= PLAYER_ROT_SPEED * self.game.delta_time + if keys[pg.K_RIGHT]: + self.rot += PLAYER_ROT_SPEED * self.game.delta_time + self.rot %= 2 * math.tau # tau = 2 * pi + + def check_wall(self, x, y): + return (x, y) not in self.game.map.world_map + + def check_collision(self, dx, dy): + scale = PLAYER_SIZE_SCALE / self.game.delta_time + if self.check_wall(int(self.x + dx * scale), int(self.y)): + self.x += dx + if self.check_wall(int(self.x), int(self.y + dy * scale)): + self.y += dy + + def draw(self): + pg.draw.line(self.game.screen, 'green', (self.x * 100, self.y * 100), + (self.x * 100 + WIDTH * math.cos(self.rot), + self.y * 100 + WIDTH * math.sin(self.rot)), 2) + pg.draw.circle(self.game.screen, 'red', (self.x * 100, self.y * 100), 15) + + def mouse_control(self): + mx, my = pg.mouse.get_pos() + if mx < MOUSE_BORDER_LEFT or mx > MOUSE_BORDER_RIGHT: + pg.mouse.set_pos([HALF_WIDTH, HALF_HEIGHT]) + self.rel = pg.mouse.get_rel()[0] + self.rel = max(-MOUSE_MAX_SPEED, min(MOUSE_MAX_SPEED, self.rel)) + self.rot += self.rel * MOUSE_SENSITIVITY * self.game.delta_time + + def update(self): + self.movement() + self.mouse_control() + + @property + def pos(self): + return self.x, self.y + + @property + def map_pos(self): + return int(self.x), int(self.y) \ No newline at end of file diff --git a/raycasting.py b/raycasting.py new file mode 100644 index 0000000..4f4431f --- /dev/null +++ b/raycasting.py @@ -0,0 +1,115 @@ +import pygame as pg +import math +from settings import * + +class RayCasting(): + def __init__(self, game): + self.game = game + self.ray_casting_result = [] + self.objects_to_render = [] + self.textures = self.game.object_renderer.wall_textures + + def get_objects_to_render(self): + self.objects_to_render = [] + for ray, values in enumerate(self.ray_casting_result): + depth, proj_height, texture, offset = values + + if proj_height < HEIGHT: + wall_column = self.textures[texture].subsurface( + offset * (TEXTURE_SIZE - SCALE), 0, SCALE, TEXTURE_SIZE + ) + wall_column = pg.transform.scale(wall_column, (SCALE, proj_height)) + wall_pos = (ray * SCALE, HALF_HEIGHT - proj_height // 2) + else: + texture_height = TEXTURE_SIZE * HEIGHT / proj_height + wall_column = self.textures[texture].subsurface( + offset * (TEXTURE_SIZE - SCALE), HALF_TEXTURE_SIZE - texture_height // 2, + SCALE, texture_height + ) + wall_column = pg.transform.scale(wall_column, (SCALE, HEIGHT)) + wall_pos = (ray * SCALE, 0) + + self.objects_to_render.append((depth, wall_column, wall_pos)) + + def ray_cast(self): + self.ray_casting_result = [] + ox, oy = self.game.player.pos + x_map, y_map = self.game.player.map_pos + + texture_vert, texture_horz = 1, 1 + + ray_angle = self.game.player.rot - HALF_FOV + 0.0001 # 0.0001 to avoid division by zero + for ray in range(NUM_RAYS): + sin_a = math.sin(ray_angle) + cos_a = math.cos(ray_angle) + + # horizontal + y_horz, dy = (y_map + 1, 1) if sin_a > 0 else (y_map - 1e-6, -1) + + depth_horz = (y_horz - oy) / sin_a + x_horz = ox + depth_horz * cos_a + + delta_depth = dy / sin_a + dx = delta_depth * cos_a + + for i in range(MAX_DEPTH): + tile_horz = int(x_horz), int(y_horz) + if tile_horz in self.game.map.world_map: + texture_horz = self.game.map.world_map[tile_horz] + break + x_horz += dx + y_horz += dy + depth_horz += delta_depth + + # verticals + x_vert, dx = (x_map + 1, 1) if cos_a > 0 else (x_map - 1e-6, -1) + + depth_vert = (x_vert - ox) / cos_a + y_vert = oy + depth_vert * sin_a + + delta_depth = dx / cos_a + dy = delta_depth * sin_a + + for i in range(MAX_DEPTH): + tile_vert = int(x_vert), int(y_vert) + if tile_vert in self.game.map.world_map: + texture_vert = self.game.map.world_map[tile_vert] + break + x_vert += dx + y_vert += dy + depth_vert += delta_depth + + # Select the shortest distance to the wall, and the corresponding texture + if depth_vert < depth_horz: + depth, texture = depth_vert, texture_vert + y_vert %= 1 + offset = y_vert if cos_a > 0 else (1 - y_vert) + else: + depth, texture = depth_horz, texture_horz + x_horz %= 1 + offset = (1 - x_horz) if sin_a > 0 else x_horz + + # Draw the ray for debugging + #pg.draw.line(self.game.screen, 'blue', (ox * 100, oy * 100), + # (100 * ox + 100 * depth * cos_a, 100 * oy + 100 * depth * sin_a), 1) + + # Remove fish-eye effect + depth *= math.cos(self.game.player.rot - ray_angle) + + # Projection + proj_height = SCREEN_DIST / (depth + 0.0001) # 0.0001 to avoid division by zero + + # Draw walls + #colour = [255 / (1 + depth ** 5 * 0.0002)] * 3 + #pg.draw.rect(self.game.screen, colour, + # (ray * SCALE, HALF_HEIGHT - proj_height // 2, SCALE, proj_height)) + + # Ray casting result + self.ray_casting_result.append((depth, proj_height, texture, offset)) + + ray_angle += DELTA_ANGLE + + + def update(self): + self.ray_cast() + self.get_objects_to_render() \ No newline at end of file diff --git a/resources/textures/1.jpg b/resources/textures/1.jpg new file mode 100644 index 0000000..6d3ccbd Binary files /dev/null and b/resources/textures/1.jpg differ diff --git a/resources/textures/1.png b/resources/textures/1.png new file mode 100644 index 0000000..eb70925 Binary files /dev/null and b/resources/textures/1.png differ diff --git a/resources/textures/2.png b/resources/textures/2.png new file mode 100644 index 0000000..c0ac9d1 Binary files /dev/null and b/resources/textures/2.png differ diff --git a/resources/textures/3.png b/resources/textures/3.png new file mode 100644 index 0000000..128ced5 Binary files /dev/null and b/resources/textures/3.png differ diff --git a/resources/textures/4.jpg b/resources/textures/4.jpg new file mode 100644 index 0000000..a8a1b75 Binary files /dev/null and b/resources/textures/4.jpg differ diff --git a/resources/textures/4.png b/resources/textures/4.png new file mode 100644 index 0000000..042253b Binary files /dev/null and b/resources/textures/4.png differ diff --git a/resources/textures/5.png b/resources/textures/5.png new file mode 100644 index 0000000..1e99925 Binary files /dev/null and b/resources/textures/5.png differ diff --git a/resources/textures/sky.png b/resources/textures/sky.png new file mode 100644 index 0000000..ade1e17 Binary files /dev/null and b/resources/textures/sky.png differ diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..06e6c8f --- /dev/null +++ b/settings.py @@ -0,0 +1,33 @@ +import math + +# GAME SETTINGS +RES = WIDTH, HEIGHT = 1600, 900 +HALF_WIDTH = WIDTH // 2 +HALF_HEIGHT = HEIGHT // 2 +FPS = 0 # 0 = unlimited + +PLAYER_POS = 1.5, 5 # player position on the map +PLAYER_ROT = 0 +PLAYER_SPEED = 0.004 +PLAYER_ROT_SPEED = 0.004 +PLAYER_SIZE_SCALE = 60 + +MOUSE_SENSITIVITY = 0.0003 +MOUSE_MAX_SPEED = 40 +MOUSE_BORDER_LEFT = 100 +MOUSE_BORDER_RIGHT = WIDTH - MOUSE_BORDER_LEFT + +FLOOR_COLOR = (69, 69, 69) + +FOV = math.pi / 3 +HALF_FOV = FOV / 2 +NUM_RAYS = WIDTH // 2 +HALF_NUM_RAYS = NUM_RAYS // 2 +DELTA_ANGLE = FOV / NUM_RAYS +MAX_DEPTH = 20 + +SCREEN_DIST = HALF_WIDTH / math.tan(HALF_FOV) +SCALE = WIDTH // NUM_RAYS + +TEXTURE_SIZE = 256 +HALF_TEXTURE_SIZE = TEXTURE_SIZE // 2 \ No newline at end of file