[Python] How to use sensors and ray casting for collision detection in Pygame (using a custom...

Discussão em 'Python' iniciado por Stack, Março 26, 2025.

  1. Stack

    Stack Membro Participativo

    I am making a Sonic game in Pygame and I have recently gotten my hands on a Python class that uses pygame.rect and pygame.mask in unison to create sensors that can be used to accurately detect the ground and slopes. I do not know how to actually utilise it and my implementation does not work correctly with Sonic jittering across the map (likely cause by how his position is changed according to the collision detection from the sensors). Can someone please help interpret how the custom Mask class works and how I can use it to create pixel perfect collision? The class that I used is below:

    LOOPMAX = 32
    OUT_SIDE = 256

    class_type = list[pygame.Mask, pygame.Rect, pygame.Rect]

    class Mask:

    # Copyright (c) 2023-2025 UCSTORM
    # Tous droits réservés.

    class_type = class_type

    def clear(sensor1):
    sensor1[0].clear()

    def newSensor(rect, center_point) -> class_type: # rect_to_mask
    """ INSIDE: MASK, RECT+CENTER_POINT, ORIGINAL_RECT"""
    mask = pygame.mask.from_surface(pygame.Surface((rect[2], rect[3])))
    return [mask, pygame.Rect(rect[0]+center_point[0], rect[1]+center_point[1], rect[2], rect[3]), rect]

    def surface_to_mask(surface, rect) -> class_type:
    mask = pygame.mask.from_surface(surface)
    return [mask, pygame.Rect(rect[0], rect[1], surface.get_size()[0], surface.get_size()[1]), pygame.Rect(rect[0], rect[1], surface.get_size()[0], surface.get_size()[1])]


    def blit(mask_chunk, coord, sensor) -> class_type:
    sensor[0].draw(mask_chunk[0],coord)
    return sensor

    def collide(sensor1, sensor2):
    offset = [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - sensor1[1][1]]
    overlap = sensor1[0].overlap(sensor2[0], offset)
    if overlap:
    print("Overlap found at offset:", offset, "Overlap point:", overlap)
    return overlap

    def colliderect(sensor1, sensor2):
    return sensor1[1].colliderect(sensor2[1])

    def sensor_draw(surface, sensor, color):
    pygame.draw.rect(surface, color, sensor[1])

    def rotation_sensor(sensor, MODE, center_point):
    """ POSSIBILITY: 0 ,1, 2, 3"""
    rect = [0, 0, 0, 0]
    if MODE == 0: rect = [sensor[2][0], sensor[2][1], sensor[2][2], sensor[2][3]]
    elif MODE == 1: rect = [sensor[2][1], -(sensor[2][0] + sensor[2][2]), sensor[2][3], sensor[2][2]]
    elif MODE == 2: rect = [-(sensor[2][0] + sensor[2][2]), -(sensor[2][1] + sensor[2][3]), sensor[2][2], sensor[2][3]]
    elif MODE == 3: rect = [-(sensor[2][1] + sensor[2][3]), sensor[2][0], sensor[2][3], sensor[2][2]]

    return Mask.rect_to_mask(rect, center_point)

    def collide_inside_y(sensor1, sensor2):
    running = True
    LOOP = 0

    while running:
    if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1]-LOOP)]):
    LOOP += 1
    else: running = False
    if LOOP >= LOOPMAX:
    running = False
    return LOOP

    def collide_outside_y(sensor1, sensor2):
    running = True
    LOOP = 0

    while running:
    if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1] + LOOP)]):
    LOOP += 1
    else:running = False
    if LOOP >= LOOPMAX: running = False
    return LOOP

    def collide_inside_x(sensor1, sensor2):
    running = True
    LOOP = 0

    while running:
    if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]-LOOP), sensor2[1][1] - (sensor1[1][1])]):
    LOOP += 1
    else: running = False
    if LOOP >= LOOPMAX:running = False
    return LOOP


    def collide_outside_x(sensor1, sensor2):
    running = True
    LOOP = 0

    while running:
    if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]+ LOOP), sensor2[1][1] - (sensor1[1][1])]):
    LOOP += 1
    else:running = False
    if LOOP >= LOOPMAX: running = False
    return LOOP


    def collide_inside_y_minus(sensor1, sensor2):
    running = True
    LOOP = 0

    while running:
    if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1]+LOOP)]):
    LOOP += 1
    else: running = False
    if LOOP >= LOOPMAX:running = False
    return -LOOP


    def collide_outside_y_minus(sensor1, sensor2):
    running = True
    LOOP = 0

    while running:
    if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1] - LOOP)]):
    LOOP += 1
    else:running = False
    if LOOP >= LOOPMAX: running = False
    return -LOOP


    def collide_inside_x_minus(sensor1, sensor2):
    running = True
    LOOP = 0

    while running:
    if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]+LOOP), sensor2[1][1] - (sensor1[1][1])]):
    LOOP += 1
    else: running = False
    if LOOP >= LOOPMAX:running = False
    return -LOOP


    def collide_outside_x_minus(sensor1, sensor2):
    running = True
    LOOP = 0

    while running:
    if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]- LOOP), sensor2[1][1] - (sensor1[1][1])]):
    LOOP += 1
    else:running = False
    if LOOP >= LOOPMAX: running = False
    return -LOOP


    Below is a very barebones, minimal reproducible example of my problem. You will be able to see that the overall detection works but the logic for adjusting the position does not work on the Y axis, causing the player to jitter up and down. The TMX data and the tileset imaged (both required to run example) are linked here: https://drive.google.com/file/d/16Enz4bjr414fjp5nfqRg4rm4FKiEkooE/view?usp=sharing

    import pygame
    import pytmx
    import os

    # Initialize pygame
    pygame.init()
    SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600
    screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
    pygame.display.set_caption("2D Platformer with Camera")
    clock = pygame.time.Clock()
    LOOPMAX = 32

    # Mask class with minimal required methods
    class_type = list[pygame.Mask, pygame.Rect, pygame.Rect]
    class Mask:
    @staticmethod
    def newSensor(rect, center_point):
    mask = pygame.mask.from_surface(pygame.Surface((rect[2], rect[3])))
    return [mask, pygame.Rect(rect[0]+center_point[0], rect[1]+center_point[1], rect[2], rect[3]), rect]

    @staticmethod
    def surface_to_mask(surface, rect):
    mask = pygame.mask.from_surface(surface)
    return [mask, pygame.Rect(rect[0], rect[1], surface.get_width(), surface.get_height()),
    pygame.Rect(rect[0], rect[1], surface.get_width(), surface.get_height())]

    @staticmethod
    def collide(sensor1, sensor2):
    offset = [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - sensor1[1][1]]
    return sensor1[0].overlap(sensor2[0], offset)

    @staticmethod
    def collide_inside_y_minus(sensor1, sensor2):
    loop = 0
    while loop < LOOPMAX:
    if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0],
    sensor2[1][1] - (sensor1[1][1]+loop)]):
    loop += 1
    else:
    break
    return -loop

    # Camera class
    class Camera:
    def __init__(self, width, height):
    self.viewport = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
    self.width = width
    self.height = height
    self.offset_x = 0
    self.offset_y = 0

    def apply(self, entity_rect):
    """Apply camera offset to entity rect"""
    return entity_rect.move(self.offset_x, self.offset_y)

    def update(self, target_rect):
    """Update camera position to follow the target"""
    # Center the camera on the target
    self.offset_x = SCREEN_WIDTH // 2 - target_rect.centerx
    self.offset_y = SCREEN_HEIGHT // 2 - target_rect.centery

    # Clamp camera to level boundaries
    self.offset_x = min(0, max(-(self.width - SCREEN_WIDTH), self.offset_x))
    self.offset_y = min(0, max(-(self.height - SCREEN_HEIGHT), self.offset_y))

    # Update viewport for other calculations
    self.viewport = pygame.Rect(-self.offset_x, -self.offset_y, self.width, self.height)

    # Load TMX map
    try:
    tmx_map = pytmx.load_pygame("sonic test world.tmx")
    # Calculate level dimensions based on map properties
    level_width = tmx_map.width * 64 # Assuming 64px tiles
    level_height = tmx_map.height * 64
    except Exception as e:
    print(f"Error loading TMX map: {e}")
    # Fallback to a simple level size
    level_width = 2000
    level_height = 1000
    tmx_map = None

    # Player class
    class Player:
    def __init__(self, x, y):
    self.x = float(x)
    self.y = float(y)
    self.rect = pygame.Rect(x, y, 32, 64)
    self.x_vel = 0
    self.y_vel = 0
    self.speed = 5
    self.gravity = 0.5
    self.grounded = False
    self.jump_power = -10
    self.color = (255, 0, 0)

    # Create sensors
    self.sensor_thickness = 2
    self.sensor_length = 10
    self.update_sensors()

    def update_sensors(self):
    left_rect = [self.rect.left, self.rect.bottom - 5, self.sensor_thickness, self.sensor_length]
    right_rect = [self.rect.right - self.sensor_thickness, self.rect.bottom - 5,
    self.sensor_thickness, self.sensor_length]

    self.left_sensor = Mask.newSensor(left_rect, (self.sensor_thickness // 2, 0))
    self.right_sensor = Mask.newSensor(right_rect, (self.sensor_thickness // 2, 0))

    def update(self):
    # Handle movement
    keys = pygame.key.get_pressed()
    self.x_vel = 0
    if keys[pygame.K_LEFT]: self.x_vel = -self.speed
    if keys[pygame.K_RIGHT]: self.x_vel = self.speed

    # Handle jumping
    if keys[pygame.K_SPACE] and self.grounded:
    self.y_vel = self.jump_power
    self.grounded = False

    # Apply gravity if not on ground
    if not self.grounded:
    self.y_vel += self.gravity
    # Limit fall speed
    self.y_vel = min(self.y_vel, 10)

    # Update position
    self.x += self.x_vel
    self.rect.x = int(self.x)

    self.y += self.y_vel
    self.rect.y = int(self.y)

    self.update_sensors()

    # Reset grounded state - will be set to True again if collision detected
    self.grounded = False

    def draw(self, surface, camera):
    # Draw player with camera offset
    pygame.draw.rect(surface, self.color, camera.apply(self.rect))

    # Draw sensors (for debugging)
    pygame.draw.rect(surface, (0, 0, 255), camera.apply(self.left_sensor[1]))
    pygame.draw.rect(surface, (0, 0, 255), camera.apply(self.right_sensor[1]))

    # Create tiles list
    tiles = []
    for layer in tmx_map.visible_layers:
    if isinstance(layer, pytmx.TiledTileLayer):
    for x, y, tile_gid in layer.tiles():
    if tile_gid:
    tile_x, tile_y = x * 64, y * 64
    if isinstance(tile_gid, pygame.Surface):
    tile_image = tile_gid
    else:
    # Retrieve the tile image by GID if it's not already a surface
    tile_image = tmx_map.get_tile_image_by_gid(tile_gid)
    if tile_image:
    scaled_image = pygame.transform.scale(tile_image, (64, 64))
    tile_rect = pygame.Rect(tile_x, tile_y, 64, 64)
    tiles.append((scaled_image, tile_rect, pygame.mask.from_surface(scaled_image)))

    # Create player
    player = Player(100, 100)

    # Create camera
    camera = Camera(max(level_width, 2000), max(level_height, 1000))

    # FPS display
    font = pygame.font.SysFont(None, 24)

    # Main game loop
    running = True
    ground_correction_factor = 0.5
    max_ground_correction = 10.0

    while running:
    # Handle events
    for event in pygame.event.get():
    if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
    running = False

    # Update player
    player.update()

    # Handle collisions
    ground_corrections = []
    for _, tile_rect, tile_mask in tiles:
    # Only check tiles that are near the player (optimization)
    if abs(tile_rect.x - player.rect.x) < SCREEN_WIDTH and abs(tile_rect.y - player.rect.y) < SCREEN_HEIGHT:
    # Create tile mask object
    tile_mask_obj = Mask.surface_to_mask(pygame.Surface((tile_rect.width, tile_rect.height)),
    (tile_rect.x, tile_rect.y))
    tile_mask_obj[0] = tile_mask # Set the correct mask

    # Check ground collision with both sensors
    for sensor in [player.left_sensor, player.right_sensor]:
    if Mask.collide(sensor, tile_mask_obj):
    y_correction = Mask.collide_inside_y_minus(sensor, tile_mask_obj)
    if y_correction < 0:
    ground_corrections.append(-y_correction)
    player.grounded = True

    # Apply ground correction
    if ground_corrections:
    avg_correction = sum(ground_corrections) / len(ground_corrections)
    smooth_correction = min(avg_correction * ground_correction_factor, max_ground_correction)
    player.y -= smooth_correction
    player.rect.y = int(player.y)
    player.y_vel = 0 # Reset vertical velocity when landing

    # Update camera to follow player
    camera.update(player.rect)

    # Draw everything
    screen.fill((200, 230, 255)) # Sky blue background

    # Draw only tiles that are visible on screen
    for tile_img, tile_rect, _ in tiles:
    # Check if the tile is in the viewport before drawing
    if camera.viewport.colliderect(tile_rect):
    screen.blit(tile_img, camera.apply(tile_rect))

    # Draw player
    player.draw(screen, camera)

    # Draw FPS
    fps_text = font.render(f"FPS: {int(clock.get_fps())}", True, (0, 0, 0))
    screen.blit(fps_text, (10, 10))

    # Draw coordinates
    coord_text = font.render(f"X: {int(player.x)}, Y: {int(player.y)}", True, (0, 0, 0))
    screen.blit(coord_text, (10, 30))

    pygame.display.flip()
    clock.tick(60)

    pygame.quit()


    What I expect to happen is that Sonic should be able to detect the ground using the sensors, essentially snap to the mask of the tile and from there, act like a normal platformer. If the sensors detect a change in ground level (i.e the sensors detect a pixel increase in height) then Sonic should move up/down to allow the sensors to be in contact with the mask. A good example of how this should look is below:

    [​IMG]

    What happens instead is a jittery mess of a collision system. Technically, the collisions are being detected and do work as Sonic for a brief moment is able to snap to the correct positions of the tile mask. The problem mostly lies in the calculations done AFTER the collision which is where Sonic's position is updated:

    https://imgur.com/zFPyDUF

    I got AI to help me implement this into the project, which yes, I understand is not the smartest idea when you yourself have absolutely no clue on how to even begin to understand the logic here. That's why I'm posting this issue here, so that someone may be able to help.

    EDIT: The creator of the Mask class has a Sonic Pygame project of their own which utilises it beautifully: https://youtu.be/OFjInMdYlB4

    I am not sure if the above video helps in dissecting the Mask class and how to better implement it, but it is there for reference.

    Another edit: To whom it may concern, this post has absolutely no similarities with ANY other posts regarding collision. Other posts explain how to use pygame.rect for basic collisions or pygame.mask for pixel perfect collisions. I instead am asking for help on interpreting the custom Mask class and how to use it for an advanced sensor system and ray casting for collision checks. It is something in a completely different league to other posts.

    Continue reading...

Compartilhe esta Página