import pygame
import sys
import os
import random
from collections import defaultdict

# Constants for environment object types
TREE = 'tree'
LOG = 'log'
SNOWMAN_BLUE = 'snowman_blue'
SNOWMAN_GREEN = 'snowman_green'

# Constants for world dimensions
WORLD_WIDTH = 2000
WORLD_HEIGHT = 2000

# Constants for character
CHARACTER_SIZE = (50, 50)
CHARACTER_COLOR = (0, 0, 255)  # Blue
CHARACTER_SPEED = 5  # Pixels per frame

# Constants for camera
ZOOM_STEP = 0.1
ZOOM_MAX = 2.0

# Constants for spatial grid
GRID_SIZE = 200  # Size of each grid cell in pixels

def load_texture(path):
    """
    Load an image from the specified path.
    """
    try:
        texture = pygame.image.load(path).convert_alpha()
        return texture
    except pygame.error as e:
        print(f"Unable to load texture image at {path}: {e}")
        pygame.quit()
        sys.exit()

def tile_texture(surface, texture, camera):
    """
    Tile the given texture across the entire surface, considering camera position and zoom.
    This implementation ensures that there are no gaps or grid lines by carefully calculating
    the offset and the number of tiles required to cover the screen.
    """
    texture_width, texture_height = texture.get_size()
    scaled_width = int(texture_width * camera.zoom)
    scaled_height = int(texture_height * camera.zoom)

    # Scale the texture once per frame
    if scaled_width == 0 or scaled_height == 0:
        # Prevent scaling to zero
        scaled_width = 1
        scaled_height = 1
    scaled_texture = pygame.transform.scale(texture, (scaled_width, scaled_height))

    # Calculate the offset based on camera position to ensure seamless tiling
    offset_x = -(camera.x % texture_width) * camera.zoom
    offset_y = -(camera.y % texture_height) * camera.zoom

    # Calculate how many tiles are needed to cover the screen, adding 2 to ensure coverage
    tiles_x = int(camera.screen_width / scaled_width) + 2
    tiles_y = int(camera.screen_height / scaled_height) + 2

    for i in range(tiles_x):
        for j in range(tiles_y):
            pos_x = int(offset_x + i * scaled_width)
            pos_y = int(offset_y + j * scaled_height)
            surface.blit(scaled_texture, (pos_x, pos_y))

def generate_non_overlapping_positions(world_width, world_height, textures, num_objects_dict, min_distance):
    """
    Generate non-overlapping positions for multiple types of environment objects.

    Parameters:
    - world_width: Width of the game world.
    - world_height: Height of the game world.
    - textures: Dictionary mapping object types to their texture surfaces.
    - num_objects_dict: Dictionary mapping object types to the number of instances.
    - min_distance: Minimum distance between any two objects to prevent overlapping.

    Returns:
    - List of dictionaries with keys: 'type', 'image', 'position', 'rect'.
    """
    placed_objects = []

    for obj_type, num in num_objects_dict.items():
        base_texture = textures[obj_type]
        obj_width, obj_height = base_texture.get_size()

        for _ in range(num):
            attempts = 0
            max_attempts = 100  # To prevent infinite loops

            while attempts < max_attempts:
                x = random.randint(0, world_width - obj_width)
                y = random.randint(0, world_height - obj_height)

                # If the object is a log, rotate it by a random angle
                if obj_type == LOG:
                    angle = random.uniform(0, 360)  # Random angle between 0 and 360 degrees
                    rotated_texture = pygame.transform.rotate(base_texture, angle)
                    rotated_width, rotated_height = rotated_texture.get_size()
                else:
                    rotated_texture = base_texture
                    rotated_width, rotated_height = obj_width, obj_height

                new_rect = pygame.Rect(x, y, rotated_width, rotated_height)

                # Check for overlap with existing objects
                overlap = False
                for obj in placed_objects:
                    existing_rect = obj['rect']
                    # Inflate existing rect by min_distance to enforce spacing
                    if new_rect.colliderect(existing_rect.inflate(min_distance, min_distance)):
                        overlap = True
                        break

                if not overlap:
                    placed_objects.append({
                        'type': obj_type,
                        'image': rotated_texture,
                        'position': (x, y),
                        'rect': new_rect
                    })
                    break  # Successfully placed
                attempts += 1

            if attempts == max_attempts:
                print(f"Warning: Could not place {obj_type} after {max_attempts} attempts.")

    return placed_objects

class SpatialGrid:
    """
    Spatial grid for efficient collision detection.
    Divides the world into grid cells and keeps track of objects in each cell.
    """
    def __init__(self, world_width, world_height, grid_size):
        self.grid_size = grid_size
        self.columns = (world_width // grid_size) + 1
        self.rows = (world_height // grid_size) + 1
        self.grid = defaultdict(list)

    def add_object(self, obj):
        """
        Add an object to the grid based on its rect.
        """
        rect = obj['rect']
        start_col = rect.left // self.grid_size
        end_col = rect.right // self.grid_size
        start_row = rect.top // self.grid_size
        end_row = rect.bottom // self.grid_size

        for col in range(start_col, end_col + 1):
            for row in range(start_row, end_row + 1):
                self.grid[(col, row)].append(obj)

    def get_nearby_objects(self, rect):
        """
        Retrieve objects that are in the same grid cells as the given rect.
        """
        nearby = []  # Changed from set() to list()
        start_col = rect.left // self.grid_size
        end_col = rect.right // self.grid_size
        start_row = rect.top // self.grid_size
        end_row = rect.bottom // self.grid_size

        for col in range(start_col, end_col + 1):
            for row in range(start_row, end_row + 1):
                for obj in self.grid.get((col, row), []):
                    nearby.append(obj)  # Changed from nearby.add(obj) to nearby.append(obj)

        return nearby  # Returns a list instead of a set

class Camera:
    def __init__(self, screen_width, screen_height):
        self.screen_width = screen_width
        self.screen_height = screen_height
        self.zoom = 1.0
        self.x = 0
        self.y = 0

        # Dynamically calculate ZOOM_MIN to ensure the entire world fits within the screen
        self.zoom_min = max(self.screen_width / WORLD_WIDTH, self.screen_height / WORLD_HEIGHT)
        self.zoom_max = ZOOM_MAX  # Define ZOOM_MAX as a constant

        # Initialize camera position
        self.set_position(WORLD_WIDTH // 2, WORLD_HEIGHT // 2)

    def world_to_screen(self, pos):
        """
        Convert world coordinates to screen coordinates based on camera position and zoom.
        """
        screen_x = (pos[0] - self.x) * self.zoom
        screen_y = (pos[1] - self.y) * self.zoom
        return (screen_x, screen_y)

    def set_position(self, x, y):
        """
        Set the camera position to center on (x, y).
        """
        self.x = x - (self.screen_width / 2) / self.zoom
        self.y = y - (self.screen_height / 2) / self.zoom
        # Clamp camera to world boundaries
        self.x = max(0, min(self.x, WORLD_WIDTH - self.screen_width / self.zoom))
        self.y = max(0, min(self.y, WORLD_HEIGHT - self.screen_height / self.zoom))

    def adjust_zoom(self, zoom_change):
        """
        Adjust the camera zoom by zoom_change, clamped between zoom_min and zoom_max.
        """
        new_zoom = self.zoom + zoom_change
        self.zoom = max(self.zoom_min, min(new_zoom, self.zoom_max))
        # After zooming, ensure the camera stays within world boundaries
        self.x = max(0, min(self.x, WORLD_WIDTH - self.screen_width / self.zoom))
        self.y = max(0, min(self.y, WORLD_HEIGHT - self.screen_height / self.zoom))

class Character:
    def __init__(self, x, y, size, color, speed, grid):
        self.x = x  # World x-coordinate
        self.y = y  # World y-coordinate
        self.width, self.height = size
        self.color = color
        self.speed = speed
        self.rect = pygame.Rect(self.x, self.y, self.width, self.height)
        self.grid = grid  # Reference to the spatial grid for collision checks

    def handle_movement(self, keys_pressed):
        """
        Update character position based on pressed keys with collision detection.
        """
        dx = dy = 0
        if keys_pressed[pygame.K_LEFT] or keys_pressed[pygame.K_a]:
            dx -= self.speed
        if keys_pressed[pygame.K_RIGHT] or keys_pressed[pygame.K_d]:
            dx += self.speed
        if keys_pressed[pygame.K_UP] or keys_pressed[pygame.K_w]:
            dy -= self.speed
        if keys_pressed[pygame.K_DOWN] or keys_pressed[pygame.K_s]:
            dy += self.speed

        # Attempt horizontal movement
        if dx != 0:
            new_rect = self.rect.copy()
            new_rect.x += dx
            if not self.check_collision(new_rect):
                self.x += dx
                self.rect.x += dx
            else:
                # Collision detected; stop horizontal movement
                dx = 0

        # Attempt vertical movement
        if dy != 0:
            new_rect = self.rect.copy()
            new_rect.y += dy
            if not self.check_collision(new_rect):
                self.y += dy
                self.rect.y += dy
            else:
                # Collision detected; stop vertical movement
                dy = 0

    def check_collision(self, new_rect):
        """
        Check if the new_rect collides with any environment objects.
        """
        nearby_objects = self.grid.get_nearby_objects(new_rect)
        for obj in nearby_objects:
            if new_rect.colliderect(obj['rect']):
                return True
        return False

    def draw(self, surface, camera):
        """
        Draw the character on the given surface considering the camera.
        """
        screen_pos = camera.world_to_screen((self.x, self.y))
        scaled_size = (int(self.width * camera.zoom), int(self.height * camera.zoom))
        scaled_rect = pygame.Rect(screen_pos, scaled_size)
        pygame.draw.rect(surface, self.color, scaled_rect)

def main():
    # Initialize Pygame
    pygame.init()

    # Set up fullscreen display
    info_object = pygame.display.Info()
    screen_width, screen_height = info_object.current_w, info_object.current_h
    screen = pygame.display.set_mode((screen_width, screen_height), pygame.FULLSCREEN)
    pygame.display.set_caption("Enhanced Top-Down Pygame 2D Snowy Environment")

    # Set up clock for FPS control
    clock = pygame.time.Clock()
    FPS = 60

    # Load snow texture
    snow_texture_path = os.path.join('Textures', 'snow_ground.png')
    snow_texture = load_texture(snow_texture_path)

    # Load environment textures
    textures = {
        TREE: load_texture(os.path.join('Textures', 'trees_snowy.png')),
        LOG: load_texture(os.path.join('Textures', 'log.png')),
        SNOWMAN_BLUE: load_texture(os.path.join('Textures', 'snowman_blue.png')),
        SNOWMAN_GREEN: load_texture(os.path.join('Textures', 'snowman_green.png'))
    }

    # Scale trees to be slightly bigger
    TREE_SCALE_FACTOR = 1.2  # 20% larger
    tree_original = textures[TREE]
    tree_width, tree_height = tree_original.get_size()
    tree_scaled = pygame.transform.scale(
        tree_original,
        (int(tree_width * TREE_SCALE_FACTOR), int(tree_height * TREE_SCALE_FACTOR))
    )
    textures[TREE] = tree_scaled  # Update the texture in the dictionary

    # Define number of each object
    num_objects = {
        TREE: 50,            # Adjust based on desired density
        LOG: 30,             # Number of logs
        SNOWMAN_BLUE: 6,     # Number of blue snowmen
        SNOWMAN_GREEN: 7     # Number of green snowmen
    }

    # Define minimum distance between objects
    # Since trees are the largest, use their scaled size as a base
    MIN_DISTANCE = max(textures[TREE].get_width(), textures[TREE].get_height())

    # Generate non-overlapping positions for all environment objects
    placed_objects = generate_non_overlapping_positions(
        WORLD_WIDTH,
        WORLD_HEIGHT,
        textures,
        num_objects,
        MIN_DISTANCE // 2  # Allow closer placement but prevent significant overlaps
    )

    # Initialize Spatial Grid for collision optimization
    spatial_grid = SpatialGrid(WORLD_WIDTH, WORLD_HEIGHT, GRID_SIZE)
    for obj in placed_objects:
        spatial_grid.add_object(obj)

    # Initialize Camera
    camera = Camera(screen_width, screen_height)

    # Initialize Character at center of the world
    character = Character(
        x=WORLD_WIDTH // 2 - CHARACTER_SIZE[0] // 2,
        y=WORLD_HEIGHT // 2 - CHARACTER_SIZE[1] // 2,
        size=CHARACTER_SIZE,
        color=CHARACTER_COLOR,
        speed=CHARACTER_SPEED,
        grid=spatial_grid
    )

    # Set initial camera position to center on the character
    camera.set_position(character.x + character.width // 2, character.y + character.height // 2)

    # Main game loop
    running = True
    while running:
        # Event Handling
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            # Exit fullscreen on pressing ESC
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    running = False
                # Zoom in
                if event.key in [pygame.K_EQUALS, pygame.K_PLUS]:
                    camera.adjust_zoom(ZOOM_STEP)
                # Zoom out
                if event.key == pygame.K_MINUS:
                    camera.adjust_zoom(-ZOOM_STEP)
            # Handle mouse wheel for zoom
            if event.type == pygame.MOUSEBUTTONDOWN:
                if event.button == 4:  # Scroll up
                    camera.adjust_zoom(ZOOM_STEP)
                if event.button == 5:  # Scroll down
                    camera.adjust_zoom(-ZOOM_STEP)

        # Handle continuous key presses for movement
        keys_pressed = pygame.key.get_pressed()
        character.handle_movement(keys_pressed)

        # Update camera to follow the character
        camera.set_position(character.x + character.width // 2, character.y + character.height // 2)

        # Create a background surface for the current view
        background = pygame.Surface((screen_width, screen_height))

        # Tile the snow texture on the background
        tile_texture(background, snow_texture, camera)

        # Draw all environment objects
        for obj in placed_objects:
            obj_x, obj_y = obj['position']
            obj_image = obj['image']
            # Convert world position to screen position
            screen_pos = camera.world_to_screen((obj_x, obj_y))
            # Scale the object image based on zoom
            scaled_image = pygame.transform.scale(
                obj_image,
                (int(obj_image.get_width() * camera.zoom), int(obj_image.get_height() * camera.zoom))
            )
            # Blit only if the object is within the visible screen
            obj_rect = pygame.Rect(screen_pos, scaled_image.get_size())
            if obj_rect.colliderect(pygame.Rect(0, 0, screen_width, screen_height)):
                background.blit(scaled_image, screen_pos)

        # Draw the character on the background
        character.draw(background, camera)

        # Blit the background to the screen
        screen.blit(background, (0, 0))

        # Update the display
        pygame.display.flip()

        # Maintain FPS
        clock.tick(FPS)

    # Quit Pygame
    pygame.quit()
    sys.exit()

if __name__ == "__main__":
    main()

Make collision ignore some types of objects such as logs, provide full updated code with changes