Hello! Can you add here frustum culling, and other culling features?
import pygame
import math
import numpy as np
import random
from collections import deque

pygame.init()

display_info = pygame.display.Info()
SCREEN_WIDTH = display_info.current_w
SCREEN_HEIGHT = display_info.current_h
FOV = math.pi / 3
HALF_FOV = FOV / 2
NUM_RAYS = SCREEN_WIDTH // 2
MAX_DEPTH = 800
CELL_SIZE = 64
PLAYER_SIZE = 10
FLOOR_COLOR = (30, 30, 30)
CEILING_COLOR = (50, 50, 80)
TEXTURE_SIZE = 64

WALL_TEXTURES = {
    1: np.array([[(int((i ^ j) % 2) * 255) for i in range(TEXTURE_SIZE)] for j in range(TEXTURE_SIZE)], dtype=np.uint8),
    2: np.array([[(int((i * j) % 32) * 8) for i in range(TEXTURE_SIZE)] for j in range(TEXTURE_SIZE)], dtype=np.uint8),
    3: np.array([[int(abs(127 - i) * 2) for i in range(TEXTURE_SIZE)] for j in range(TEXTURE_SIZE)], dtype=np.uint8)
}

def generate_dungeon(width, height, max_rooms, room_min_size, room_max_size):
    dungeon = [[1 for _ in range(width)] for _ in range(height)]
    rooms = []
    for _ in range(max_rooms):
        w = random.randint(room_min_size, room_max_size)
        h = random.randint(room_min_size, room_max_size)
        x = random.randint(1, width - w - 1)
        y = random.randint(1, height - h - 1)
        new_room = {'x1': x, 'y1': y, 'x2': x + w, 'y2': y + h}
        failed = False
        for other_room in rooms:
            if (new_room['x1'] <= other_room['x2'] and new_room['x2'] >= other_room['x1'] and
                new_room['y1'] <= other_room['y2'] and new_room['y2'] >= other_room['y1']):
                failed = True
                break
        if not failed:
            rooms.append(new_room)
            for i in range(new_room['x1'], new_room['x2']):
                for j in range(new_room['y1'], new_room['y2']):
                    dungeon[j][i] = 0
            if len(rooms) > 1:
                prev_room = rooms[-2]
                current_center = center(new_room)
                prev_center = center(prev_room)
                if random.randint(0, 1) == 1:
                    create_h_tunnel(dungeon, prev_center[0], current_center[0], prev_center[1])
                    create_v_tunnel(dungeon, prev_center[1], current_center[1], current_center[0])
                else:
                    create_v_tunnel(dungeon, prev_center[1], current_center[1], prev_center[0])
                    create_h_tunnel(dungeon, prev_center[0], current_center[0], current_center[1])
    decorate_walls(dungeon)
    return dungeon

def center(room):
    x = (room['x1'] + room['x2']) // 2
    y = (room['y1'] + room['y2']) // 2
    return x, y

def create_h_tunnel(dungeon, x1, x2, y):
    for x in range(min(x1, x2), max(x1, x2) + 1):
        dungeon[y][x] = 0

def create_v_tunnel(dungeon, y1, y2, x):
    for y in range(min(y1, y2), max(y1, y2) + 1):
        dungeon[y][x] = 0

def decorate_walls(dungeon):
    for y in range(len(dungeon)):
        for x in range(len(dungeon[0])):
            if dungeon[y][x] == 1 and random.random() < 0.1:
                dungeon[y][x] = random.choice([2, 3])

def is_map_connected(dungeon, start_position):
    width = len(dungeon[0])
    height = len(dungeon)
    visited = [[False for _ in range(width)] for _ in range(height)]
    queue = deque()
    x, y = start_position
    queue.append((y, x))
    visited[y][x] = True
    while queue:
        cy, cx = queue.popleft()
        for dx, dy in [(-1,0),(1,0),(0,-1),(0,1)]:
            nx, ny = cx + dx, cy + dy
            if 0 <= nx < width and 0 <= ny < height:
                if not visited[ny][nx] and dungeon[ny][nx] == 0:
                    visited[ny][nx] = True
                    queue.append((ny, nx))
    for y in range(height):
        for x in range(width):
            if dungeon[y][x] == 0 and not visited[y][x]:
                return False
    return True

def generate_connected_dungeon(width, height, max_rooms, room_min_size, room_max_size):
    max_attempts = 35
    for _ in range(max_attempts):
        dungeon = generate_dungeon(width, height, max_rooms, room_min_size, room_max_size)
        start_x, start_y = find_player_start(dungeon)
        if is_map_connected(dungeon, (start_x, start_y)):
            return dungeon, start_x, start_y
    raise Exception("Failed to generate a connected dungeon")

def find_player_start(dungeon):
    width = len(dungeon[0])
    height = len(dungeon)
    while True:
        x = random.randint(1, width - 2)
        y = random.randint(1, height - 2)
        if dungeon[y][x] == 0:
            return x, y

MAP_WIDTH = 50
MAP_HEIGHT = 50
MAX_ROOMS = 15
ROOM_MIN_SIZE = 5
ROOM_MAX_SIZE = 10
MAP, player_cell_x, player_cell_y = generate_connected_dungeon(MAP_WIDTH, MAP_HEIGHT, MAX_ROOMS, ROOM_MIN_SIZE, ROOM_MAX_SIZE)

player_x = player_cell_x * CELL_SIZE + CELL_SIZE / 2
player_y = player_cell_y * CELL_SIZE + CELL_SIZE / 2

screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.NOFRAME | pygame.FULLSCREEN)

pygame.mouse.set_visible(False)
pygame.event.set_grab(True)
MOUSE_SENSITIVITY = 0.002

player_angle = 0
player_speed = 150
rotation_speed = 2
bob_amount = 5
bob_speed = 10
bob_angle = 0

class TextureCache:
    def __init__(self):
        self.wall_textures = {}
        self._generate_textures()
        
    def _generate_textures(self):
        for wall_type, pattern in WALL_TEXTURES.items():
            surface = pygame.Surface((TEXTURE_SIZE, TEXTURE_SIZE))
            pygame.surfarray.blit_array(surface, np.stack([pattern]*3, axis=-1))
            self.wall_textures[wall_type] = surface.convert()

texture_cache = TextureCache()

def cast_ray(angle):
    sin_a = math.sin(angle)
    cos_a = math.cos(angle)
    
    EPSILON = 1e-10
    if abs(sin_a) < EPSILON: sin_a = EPSILON
    if abs(cos_a) < EPSILON: cos_a = EPSILON

    player_cell_x = int(player_x / CELL_SIZE)
    player_cell_y = int(player_y / CELL_SIZE)

    delta_dist_x = abs(CELL_SIZE / cos_a)
    delta_dist_y = abs(CELL_SIZE / sin_a)

    step_x = 1 if cos_a >= 0 else -1
    step_y = 1 if sin_a >= 0 else -1

    side_dist_x = ((player_cell_x + 1) * CELL_SIZE - player_x) / cos_a if cos_a >= 0 else (player_x - player_cell_x * CELL_SIZE) / -cos_a
    side_dist_y = ((player_cell_y + 1) * CELL_SIZE - player_y) / sin_a if sin_a >= 0 else (player_y - player_cell_y * CELL_SIZE) / -sin_a

    distance = 0
    map_width = len(MAP[0])
    map_height = len(MAP)

    while distance < MAX_DEPTH:
        if side_dist_x < side_dist_y:
            side_dist_x += delta_dist_x
            player_cell_x += step_x
            side = 0
        else:
            side_dist_y += delta_dist_y
            player_cell_y += step_y
            side = 1

        if 0 <= player_cell_x < map_width and 0 <= player_cell_y < map_height:
            wall_type = MAP[player_cell_y][player_cell_x]
            if wall_type > 0:
                if side == 0:
                    distance = (player_cell_x * CELL_SIZE - player_x + (1 - step_x) * CELL_SIZE / 2) / cos_a
                    texture_pos = (player_y + distance * sin_a) % CELL_SIZE / CELL_SIZE
                else:
                    distance = (player_cell_y * CELL_SIZE - player_y + (1 - step_y) * CELL_SIZE / 2) / sin_a
                    texture_pos = (player_x + distance * cos_a) % CELL_SIZE / CELL_SIZE
                return distance, angle, wall_type, texture_pos
        else:
            return MAX_DEPTH, angle, 0, 0

    return MAX_DEPTH, angle, 0, 0

def draw_scene():
    screen.fill(CEILING_COLOR, (0, 0, SCREEN_WIDTH, SCREEN_HEIGHT // 2))
    screen.fill(FLOOR_COLOR, (0, SCREEN_HEIGHT // 2, SCREEN_WIDTH, SCREEN_HEIGHT // 2))
    
    wall_slice_width = SCREEN_WIDTH / NUM_RAYS
    
    for i in range(NUM_RAYS):
        ray_angle = (player_angle - HALF_FOV) + (i / NUM_RAYS) * FOV
        distance, angle, wall_type, texture_pos = cast_ray(ray_angle)
        if wall_type > 0:
            distance *= math.cos(angle - player_angle)
            wall_height = (CELL_SIZE * SCREEN_HEIGHT) / (distance + 0.0001)
            wall_top = (SCREEN_HEIGHT - wall_height) / 2 + math.sin(bob_angle) * bob_amount
            wall_bottom = wall_top + wall_height

            texture = texture_cache.wall_textures[wall_type]
            texture_x = int(texture_pos * (TEXTURE_SIZE - 1))
            column = texture.subsurface(texture_x, 0, 1, TEXTURE_SIZE)
            column = pygame.transform.scale(column, (int(wall_slice_width) + 1, int(wall_height)))

            fog_factor = max(0, min(1, (MAX_DEPTH - distance) / MAX_DEPTH))
            if fog_factor < 1:
                column.set_alpha(int(255 * fog_factor))

            x = int(i * wall_slice_width)
            screen.blit(column, (x, wall_top))
    
    draw_minimap()

def draw_minimap():
    MINIMAP_SCALE = 5
    minimap_width = MAP_WIDTH * MINIMAP_SCALE
    minimap_height = MAP_HEIGHT * MINIMAP_SCALE
    minimap_surface = pygame.Surface((minimap_width, minimap_height))
    minimap_surface.set_alpha(200)
    colors = {
        0: (50, 50, 50),
        1: (200, 0, 0),
        2: (0, 200, 0),
        3: (0, 0, 200)
    }
    for y in range(MAP_HEIGHT):
        for x in range(MAP_WIDTH):
            cell_value = MAP[y][x]
            color = colors.get(cell_value, (255, 255, 255))
            rect = (x * MINIMAP_SCALE, y * MINIMAP_SCALE, MINIMAP_SCALE, MINIMAP_SCALE)
            minimap_surface.fill(color, rect)
    px = player_x / CELL_SIZE * MINIMAP_SCALE
    py = player_y / CELL_SIZE * MINIMAP_SCALE
    pygame.draw.circle(minimap_surface, (255, 255, 0), (int(px), int(py)), 3)
    pdx = math.cos(player_angle) * 5
    pdy = math.sin(player_angle) * 5
    pygame.draw.line(minimap_surface, (255, 255, 0), (px, py), (px + pdx, py + pdy), 2)
    screen.blit(minimap_surface, (10, 10))

running = True
clock = pygame.time.Clock()
fps_update_timer = 0
fps_update_interval = 0.5
fullscreen = True

while running:
    dt = clock.tick(60) / 1000.0

    fps_update_timer += dt
    if fps_update_timer >= fps_update_interval:
        fps = clock.get_fps()
        pygame.display.set_caption(f'FPS: {int(fps)}')
        fps_update_timer = 0

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_RETURN and (event.mod & pygame.KMOD_ALT):
                fullscreen = not fullscreen
                if fullscreen:
                    screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.NOFRAME | pygame.FULLSCREEN)
                    SCREEN_WIDTH, SCREEN_HEIGHT = screen.get_size()
                else:
                    screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.RESIZABLE)
            elif event.key == pygame.K_ESCAPE:
                visible = pygame.mouse.get_visible()
                pygame.mouse.set_visible(not visible)
                pygame.event.set_grab(visible)
        elif event.type == pygame.MOUSEMOTION and pygame.event.get_grab():
            player_angle += event.rel[0] * MOUSE_SENSITIVITY

    keys = pygame.key.get_pressed()
    current_speed = player_speed * dt
    sprint = False
    if keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT]:
        current_speed *= 1.5
        sprint = True

    sin_a = math.sin(player_angle)
    cos_a = math.cos(player_angle)

    dx = dy = 0
    if keys[pygame.K_w]:
        dx += cos_a * current_speed
        dy += sin_a * current_speed
    if keys[pygame.K_s]:
        dx -= cos_a * current_speed
        dy -= sin_a * current_speed
    if keys[pygame.K_a]:
        dx += sin_a * current_speed
        dy -= cos_a * current_speed
    if keys[pygame.K_d]:
        dx -= sin_a * current_speed
        dy += cos_a * current_speed

    if dx != 0 or dy != 0:
        next_x = player_x + dx
        next_y = player_y + dy
        next_cell_x = int(next_x / CELL_SIZE)
        next_cell_y = int(next_y / CELL_SIZE)
        if 0 <= next_cell_x < len(MAP[0]) and 0 <= next_cell_y < len(MAP):
            if MAP[int(player_y / CELL_SIZE)][next_cell_x] == 0:
                player_x = next_x
            if MAP[next_cell_y][int(player_x / CELL_SIZE)] == 0:
                player_y = next_y
        bob_angle += bob_speed * dt * (1.5 if sprint else 1)
    else:
        bob_angle = 0

    draw_scene()
    pygame.display.flip()

pygame.quit()