diff --git a/player.py b/player.py new file mode 100644 index 0000000..78a00b3 --- /dev/null +++ b/player.py @@ -0,0 +1,104 @@ +from settings import * +import pygame +import math +from map import collision_walls + +class Player: + def __init__(self, sprites): + self.x, self.y = player_pos + self.sprites = sprites + self.angle = player_angle + self.sensitivity = 0.004 + # collision parameters + self.side = 50 + self.rect = pygame.Rect(*player_pos, self.side, self.side) + # weapon + self.shot = False + self.health = PLAYER_HEALTH + + def get_damage(self, damage): + self.health -= damage + + @property + def pos(self): + return (self.x, self.y) + + @property + def collision_list(self): + return collision_walls + [pygame.Rect(*obj.pos, obj.side, obj.side) for obj in + self.sprites.list_of_objects if obj.blocked] + + def detect_collision(self, dx, dy): + next_rect = self.rect.copy() + next_rect.move_ip(dx, dy) + hit_indexes = next_rect.collidelistall(self.collision_list) + + if len(hit_indexes): + delta_x, delta_y = 0, 0 + for hit_index in hit_indexes: + hit_rect = self.collision_list[hit_index] + if dx > 0: + delta_x += next_rect.right - hit_rect.left + else: + delta_x += hit_rect.right - next_rect.left + if dy > 0: + delta_y += next_rect.bottom - hit_rect.top + else: + delta_y += hit_rect.bottom - next_rect.top + + if abs(delta_x - delta_y) < 10: + dx, dy = 0, 0 + elif delta_x > delta_y: + dy = 0 + elif delta_y > delta_x: + dx = 0 + self.x += dx + self.y += dy + + def movement(self): + self.keys_control() + self.mouse_control() + self.rect.center = self.x, self.y + self.angle %= DOUBLE_PI + + def keys_control(self): + sin_a = math.sin(self.angle) + cos_a = math.cos(self.angle) + keys = pygame.key.get_pressed() + if keys[pygame.K_ESCAPE]: + exit() + + if keys[pygame.K_w]: + dx = player_speed * cos_a + dy = player_speed * sin_a + self.detect_collision(dx, dy) + if keys[pygame.K_s]: + dx = -player_speed * cos_a + dy = -player_speed * sin_a + self.detect_collision(dx, dy) + if keys[pygame.K_a]: + dx = player_speed * sin_a + dy = -player_speed * cos_a + self.detect_collision(dx, dy) + if keys[pygame.K_d]: + dx = -player_speed * sin_a + dy = player_speed * cos_a + self.detect_collision(dx, dy) + + if keys[pygame.K_LEFT]: + self.angle -= 0.02 + if keys[pygame.K_RIGHT]: + self.angle += 0.02 + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + exit() + if event.type == pygame.MOUSEBUTTONDOWN: + if event.button == 1 and not self.shot: + self.shot = True + + def mouse_control(self): + if pygame.mouse.get_focused(): + difference = pygame.mouse.get_pos()[0] - HALF_WIDTH + pygame.mouse.set_pos((HALF_WIDTH, HALF_HEIGHT)) + self.angle += difference * self.sensitivity \ No newline at end of file diff --git a/ray_casting.py b/ray_casting.py new file mode 100644 index 0000000..ca9c306 --- /dev/null +++ b/ray_casting.py @@ -0,0 +1,76 @@ +import pygame +from settings import * +from map import world_map, WORLD_WIDTH, WORLD_HEIGHT +from numba import njit + +@njit(fastmath=True, cache=True) +def mapping(a, b): + return int(a // TILE) * TILE, int(b // TILE) * TILE + +@njit(fastmath=True, cache=True) +def ray_casting(player_pos, player_angle, world_map): + casted_walls = [] + ox, oy = player_pos + texture_v, texture_h = 1, 1 + xm, ym = mapping(ox, oy) + cur_angle = player_angle - HALF_FOV + for ray in range(NUM_RAYS): + sin_a = math.sin(cur_angle) + sin_a = sin_a if sin_a else 0.000001 + cos_a = math.cos(cur_angle) + cos_a = cos_a if cos_a else 0.000001 + + # verticals + x, dx = (xm + TILE, 1) if cos_a >= 0 else (xm, -1) + for i in range(0, WORLD_WIDTH, TILE): + depth_v = (x - ox) / cos_a + yv = oy + depth_v * sin_a + tile_v = mapping(x + dx, yv) + if tile_v in world_map: + texture_v = world_map[tile_v] + break + x += dx * TILE + + # horizontals + y, dy = (ym + TILE, 1) if sin_a >= 0 else (ym, -1) + for i in range(0, WORLD_HEIGHT, TILE): + depth_h = (y - oy) / sin_a + xh = ox + depth_h * cos_a + tile_h = mapping(xh, y + dy) + if tile_h in world_map: + texture_h = world_map[tile_h] + break + y += dy * TILE + + # projection + depth, offset, texture = (depth_v, yv, texture_v) if depth_v < depth_h else (depth_h, xh, texture_h) + offset = int(offset) % TILE + depth *= math.cos(player_angle - cur_angle) + depth = max(depth, 0.00001) + proj_height = int(PROJ_COEFF / depth) + + casted_walls.append((depth, offset, proj_height, texture)) + cur_angle += DELTA_ANGLE + return casted_walls + +def ray_casting_walls(player, textures): + casted_walls = ray_casting(player.pos, player.angle, world_map) + wall_shot = casted_walls[CENTER_RAY][0], casted_walls[CENTER_RAY][2] + walls = [] + for ray, casted_values in enumerate(casted_walls): + depth, offset, proj_height, texture = casted_values + if proj_height > HEIGHT: + coeff = proj_height / HEIGHT + texture_height = TEXTURE_HEIGHT / coeff + wall_column = textures[texture].subsurface(offset * TEXTURE_SCALE, + HALF_TEXTURE_HEIGHT - texture_height // 2, + TEXTURE_SCALE, texture_height) + wall_column = pygame.transform.scale(wall_column, (SCALE, HEIGHT)) + wall_pos = (ray * SCALE, 0) + else: + wall_column = textures[texture].subsurface(offset * TEXTURE_SCALE, 0, TEXTURE_SCALE, TEXTURE_HEIGHT) + wall_column = pygame.transform.scale(wall_column, (SCALE, proj_height)) + wall_pos = (ray * SCALE, HALF_HEIGHT - proj_height // 2) + + walls.append((depth, wall_column, wall_pos)) + return walls, wall_shot diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6050c33 Binary files /dev/null and b/requirements.txt differ diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..44f801c --- /dev/null +++ b/settings.py @@ -0,0 +1,61 @@ +import math + +# game settings +WIDTH = 1920 +HEIGHT = 1080 +HALF_WIDTH = WIDTH // 2 +HALF_HEIGHT = HEIGHT // 2 +PENTA_HEIGHT = 5 * HEIGHT +DOUBLE_HEIGHT = 2 * HEIGHT +FPS = 60 +TILE = 100 +FPS_POS = (WIDTH - 65, 5) + +# minimap settings +MINIMAP_SCALE = 5 +MINIMAP_RES = (WIDTH // MINIMAP_SCALE, HEIGHT // MINIMAP_SCALE) +MAP_SCALE = 1.25 * MINIMAP_SCALE # 1 -> 12 x 8, 2 -> 24 x 16, 3 -> 36 x 24 +MAP_TILE = TILE // MAP_SCALE +MAP_POS = (0, HEIGHT - HEIGHT // MINIMAP_SCALE) + +# ray casting settings +FOV = math.pi / 3 +HALF_FOV = FOV / 2 +NUM_RAYS = 300 +MAX_DEPTH = 800 +DELTA_ANGLE = FOV / NUM_RAYS +DIST = NUM_RAYS / (2 * math.tan(HALF_FOV)) +PROJ_COEFF = 3 * DIST * TILE +SCALE = WIDTH // NUM_RAYS + +# sprite settings +DOUBLE_PI = math.pi * 2 +CENTER_RAY = NUM_RAYS // 2 - 1 +FAKE_RAYS = 100 +FAKE_RAYS_RANGE = NUM_RAYS - 1 + 2 * FAKE_RAYS + +# texture settings (1200 x 1200) +TEXTURE_WIDTH = 1200 +TEXTURE_HEIGHT = 1200 +HALF_TEXTURE_HEIGHT = TEXTURE_HEIGHT // 2 +TEXTURE_SCALE = TEXTURE_WIDTH // TILE + +# player settings +player_pos = (HALF_WIDTH // 4, HALF_HEIGHT - 50) +player_angle = 0 +player_speed = 3 +PLAYER_HEALTH = 100 + +# colors +WHITE = (255, 255, 255) +BLACK = (0, 0, 0) +RED = (220, 0, 0) +GREEN = (0, 80, 0) +BLUE = (0, 0, 255) +DARKGRAY = (40, 40, 40) +PURPLE = (120, 0, 120) +SKYBLUE = (0, 186, 255) +YELLOW = (220, 220, 0) +SANDY = (244, 164, 96) +DARKBROWN = (97, 61, 25) +DARKORANGE = (255, 140, 0) \ No newline at end of file diff --git a/sprite_objects.py b/sprite_objects.py new file mode 100644 index 0000000..b58e6ef --- /dev/null +++ b/sprite_objects.py @@ -0,0 +1,268 @@ +import pygame +from settings import * +from collections import deque +from ray_casting import mapping +from numba.core import types +from numba.typed import Dict +from numba import int32 + + +class Sprites: + def __init__(self): + self.sprite_parameters = { + 'sprite_barrel': { + 'sprite': pygame.image.load('sprites/barrel/base/0.png').convert_alpha(), + 'viewing_angles': None, + 'shift': 1.8, + 'scale': (0.4, 0.4), + 'side': 30, + 'damage': 0, + 'animation': deque( + [pygame.image.load(f'sprites/barrel/anim/{i}.png').convert_alpha() for i in range(12)]), + 'death_animation': deque([pygame.image.load(f'sprites/barrel/death/{i}.png') + .convert_alpha() for i in range(4)]), + 'is_dead': None, + 'dead_shift': 2.6, + 'animation_dist': 800, + 'animation_speed': 10, + 'blocked': True, + 'flag': 'decor', + 'obj_action': [] + }, + 'sprite_pin': { + 'sprite': pygame.image.load('sprites/pin/base/0.png').convert_alpha(), + 'viewing_angles': None, + 'shift': 0.6, + 'scale': (0.6, 0.6), + 'side': 30, + 'damage': 0, + 'animation': deque([pygame.image.load(f'sprites/pin/anim/{i}.png').convert_alpha() for i in range(8)]), + 'death_animation': [], + 'is_dead': 'immortal', + 'dead_shift': None, + 'animation_dist': 800, + 'animation_speed': 10, + 'blocked': True, + 'flag': 'decor', + 'obj_action': [] + }, + 'sprite_flame': { + 'sprite': pygame.image.load('sprites/flame/base/0.png').convert_alpha(), + 'viewing_angles': None, + 'shift': 0.7, + 'scale': (0.6, 0.6), + 'side': 30, + 'damage': 0, + 'animation': deque( + [pygame.image.load(f'sprites/flame/anim/{i}.png').convert_alpha() for i in range(16)]), + 'death_animation': [], + 'is_dead': 'immortal', + 'dead_shift': 1.8, + 'animation_dist': 1800, + 'animation_speed': 5, + 'blocked': None, + 'flag': 'decor', + 'obj_action': [] + }, + 'npc_devil': { + 'sprite': [pygame.image.load(f'sprites/devil/base/{i}.png').convert_alpha() for i in range(8)], + 'viewing_angles': True, + 'shift': 0.0, + 'scale': (1.1, 1.1), + 'side': 50, + 'damage': 2, + 'animation': [], + 'death_animation': deque([pygame.image.load(f'sprites/devil/death/{i}.png') + .convert_alpha() for i in range(6)]), + 'is_dead': None, + 'dead_shift': 0.6, + 'animation_dist': None, + 'animation_speed': 10, + 'blocked': True, + 'flag': 'npc', + 'obj_action': deque( + [pygame.image.load(f'sprites/devil/anim/{i}.png').convert_alpha() for i in range(9)]), + }, + 'npc_soldier0': { + 'sprite': [pygame.image.load(f'sprites/npc/soldier0/base/{i}.png').convert_alpha() for i in range(8)], + 'viewing_angles': True, + 'shift': 0.8, + 'scale': (0.4, 0.6), + 'side': 30, + 'damage': 2, + 'animation': [], + 'death_animation': deque([pygame.image.load(f'sprites/npc/soldier0/death/{i}.png') + .convert_alpha() for i in range(10)]), + 'is_dead': None, + 'dead_shift': 1.7, + 'animation_dist': None, + 'animation_speed': 6, + 'blocked': True, + 'flag': 'npc', + 'obj_action': deque([pygame.image.load(f'sprites/npc/soldier0/action/{i}.png') + .convert_alpha() for i in range(4)]) + }, + } + + self.list_of_objects = [ + SpriteObject(self.sprite_parameters['sprite_barrel'], (7.1, 2.1)), + SpriteObject(self.sprite_parameters['sprite_barrel'], (5.9, 2.1)), + SpriteObject(self.sprite_parameters['sprite_pin'], (8.7, 2.5)), + SpriteObject(self.sprite_parameters['npc_devil'], (7, 4)), + SpriteObject(self.sprite_parameters['sprite_flame'], (8.6, 5.6)), + SpriteObject(self.sprite_parameters['npc_soldier0'], (2.5, 1.5)), + SpriteObject(self.sprite_parameters['npc_soldier0'], (5.51, 1.5)), + SpriteObject(self.sprite_parameters['npc_soldier0'], (6.61, 2.92)), + SpriteObject(self.sprite_parameters['npc_soldier0'], (7.68, 1.47)), + SpriteObject(self.sprite_parameters['npc_soldier0'], (8.75, 3.65)), + SpriteObject(self.sprite_parameters['npc_soldier0'], (1.27, 11.5)), + SpriteObject(self.sprite_parameters['npc_soldier0'], (1.26, 8.29)), + ] + + @property + def sprite_shot(self): + return min([obj.is_on_fire for obj in self.list_of_objects], default=(float('inf'), 0)) + + @property + def blocked_doors(self): + blocked_doors = Dict.empty(key_type=types.UniTuple(int32, 2), value_type=int32) + for obj in self.list_of_objects: + if obj.flag in {'door_h', 'door_v'} and obj.blocked: + i, j = mapping(obj.x, obj.y) + blocked_doors[(i, j)] = 0 + return blocked_doors + + +class SpriteObject: + def __init__(self, parameters, pos): + self.object = parameters['sprite'].copy() + self.viewing_angles = parameters['viewing_angles'] + self.shift = parameters['shift'] + self.scale = parameters['scale'] + self.animation = parameters['animation'].copy() + # --------------------- + self.death_animation = parameters['death_animation'].copy() + self.is_dead = parameters['is_dead'] + self.dead_shift = parameters['dead_shift'] + # --------------------- + self.animation_dist = parameters['animation_dist'] + self.animation_speed = parameters['animation_speed'] + self.blocked = parameters['blocked'] + self.flag = parameters['flag'] + self.obj_action = parameters['obj_action'].copy() + self.x, self.y = pos[0] * TILE, pos[1] * TILE + self.side = parameters['side'] + self.damage = parameters['damage'] + self.dead_animation_count = 0 + self.animation_count = 0 + self.npc_action_trigger = False + self.door_open_trigger = False + self.door_prev_pos = self.y if self.flag == 'door_h' else self.x + self.delete = False + if self.viewing_angles: + if len(self.object) == 8: + self.sprite_angles = [frozenset(range(338, 361)) | frozenset(range(0, 23))] + \ + [frozenset(range(i, i + 45)) for i in range(23, 338, 45)] + else: + self.sprite_angles = [frozenset(range(348, 361)) | frozenset(range(0, 11))] + \ + [frozenset(range(i, i + 23)) for i in range(11, 348, 23)] + self.sprite_positions = {angle: pos for angle, pos in zip(self.sprite_angles, self.object)} + + @property + def is_on_fire(self): + if CENTER_RAY - self.side // 2 < self.current_ray < CENTER_RAY + self.side // 2 and self.blocked: + return self.distance_to_sprite, self.proj_height + return float('inf'), None + + def sprite_shot(self): + return min([obj.is_on_fire for obj in self.list_of_objects], default=(float('inf'), 0)) + + @property + def pos(self): + return self.x - self.side // 2, self.y - self.side // 2 + + def object_locate(self, player): + + dx, dy = self.x - player.x, self.y - player.y + self.distance_to_sprite = math.sqrt(dx ** 2 + dy ** 2) + + self.theta = math.atan2(dy, dx) + gamma = self.theta - player.angle + if dx > 0 and 180 <= math.degrees(player.angle) <= 360 or dx < 0 and dy < 0: + gamma += DOUBLE_PI + self.theta -= 1.4 * gamma + + delta_rays = int(gamma / DELTA_ANGLE) + self.current_ray = CENTER_RAY + delta_rays + if self.flag not in {'door_h', 'door_v'}: + self.distance_to_sprite *= math.cos(HALF_FOV - self.current_ray * DELTA_ANGLE) + + fake_ray = self.current_ray + FAKE_RAYS + if 0 <= fake_ray <= FAKE_RAYS_RANGE and self.distance_to_sprite > 30: + self.proj_height = min(int(PROJ_COEFF / self.distance_to_sprite), + DOUBLE_HEIGHT if self.flag not in {'door_h', 'door_v'} else HEIGHT) + sprite_width = int(self.proj_height * self.scale[0]) + sprite_height = int(self.proj_height * self.scale[1]) + half_sprite_width = sprite_width // 2 + half_sprite_height = sprite_height // 2 + shift = half_sprite_height * self.shift + + # logic for doors, npc, decor + if self.is_dead and self.is_dead != 'immortal': + sprite_object = self.dead_animation() + shift = half_sprite_height * self.dead_shift + sprite_height = int(sprite_height / 1.3) + elif self.npc_action_trigger: + sprite_object = self.npc_in_action() + else: + self.object = self.visible_sprite() + sprite_object = self.sprite_animation() + + + # sprite scale and pos + sprite_pos = (self.current_ray * SCALE - half_sprite_width, HALF_HEIGHT - half_sprite_height + shift) + sprite = pygame.transform.scale(sprite_object, (sprite_width, sprite_height)) + return (self.distance_to_sprite, sprite, sprite_pos) + else: + return (False,) + + def sprite_animation(self): + if self.animation and self.distance_to_sprite < self.animation_dist: + sprite_object = self.animation[0] + if self.animation_count < self.animation_speed: + self.animation_count += 1 + else: + self.animation.rotate() + self.animation_count = 0 + return sprite_object + return self.object + + def visible_sprite(self): + if self.viewing_angles: + if self.theta < 0: + self.theta += DOUBLE_PI + self.theta = 360 - int(math.degrees(self.theta)) + + for angles in self.sprite_angles: + if self.theta in angles: + return self.sprite_positions[angles] + return self.object + + def dead_animation(self): + if len(self.death_animation): + if self.dead_animation_count < self.animation_speed: + self.dead_sprite = self.death_animation[0] + self.dead_animation_count += 1 + else: + self.dead_sprite = self.death_animation.popleft() + self.dead_animation_count = 0 + return self.dead_sprite + + def npc_in_action(self): + sprite_object = self.obj_action[0] + if self.animation_count < self.animation_speed: + self.animation_count += 1 + else: + self.obj_action.rotate() + self.animation_count = 0 + return sprite_object \ No newline at end of file