Tuesday, April 1, 2025
HomeGame developmentpython - Methods to use sensors and ray casting for collision detection...

python – Methods to use sensors and ray casting for collision detection in Pygame (utilizing a customized Masks/sensor class)?


I’m making a Sonic sport in Pygame and I’ve just lately gotten my fingers on a Python class that makes use of pygame.rect and pygame.masks in unison to create sensors that can be utilized to precisely detect the bottom and slopes. I have no idea easy methods to really utilise it and my implementation doesn’t work accurately with Sonic jittering throughout the map (seemingly attributable to how his place is modified in keeping with the collision detection from the sensors). Can somebody please assist interpret how the customized Masks class works and the way I can use it to create pixel good collision? The category that I used is beneath:

LOOPMAX = 32
OUT_SIDE = 256

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

class Masks:

# 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"""
        masks = pygame.masks.from_surface(pygame.Floor((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(floor, rect) -> class_type:
        masks = pygame.masks.from_surface(floor)
        return [mask, pygame.Rect(rect[0], rect[1], floor.get_size()[0], floor.get_size()[1]), pygame.Rect(rect[0], rect[1], floor.get_size()[0], floor.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 discovered at offset:", offset, "Overlap level:", overlap)
        return overlap

    def colliderect(sensor1, sensor2):
        return sensor1[1].colliderect(sensor2[1])
    
    def sensor_draw(floor, sensor, coloration):
        pygame.draw.rect(floor, coloration, 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 Masks.rect_to_mask(rect, center_point)

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

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

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

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

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

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


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

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


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

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


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

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


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

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


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

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

Beneath is a really barebones, minimal reproducible instance of my drawback. It is possible for you to to see that the general detection works however the logic for adjusting the place doesn’t work on the Y axis, inflicting the participant to jitter up and down. The TMX information and the tile set picture (each required to run instance) are linked right 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
display screen = pygame.show.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.show.set_caption("2D Platformer with Digicam")
clock = pygame.time.Clock()
LOOPMAX = 32

# Masks class with minimal required strategies
class_type = checklist[pygame.Mask, pygame.Rect, pygame.Rect]
class Masks:
    @staticmethod
    def newSensor(rect, center_point):
        masks = pygame.masks.from_surface(pygame.Floor((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(floor, rect):
        masks = pygame.masks.from_surface(floor)
        return [mask, pygame.Rect(rect[0], rect[1], floor.get_width(), floor.get_height()), 
                pygame.Rect(rect[0], rect[1], floor.get_width(), floor.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
        whereas 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

# Digicam class
class Digicam:
    def __init__(self, width, top):
        self.viewport = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
        self.width = width
        self.top = top
        self.offset_x = 0
        self.offset_y = 0
    
    def apply(self, entity_rect):
        """Apply digicam offset to entity rect"""
        return entity_rect.transfer(self.offset_x, self.offset_y)
    
    def replace(self, target_rect):
        """Replace digicam place to observe the goal"""
        # Heart the digicam on the goal
        self.offset_x = SCREEN_WIDTH // 2 - target_rect.centerx
        self.offset_y = SCREEN_HEIGHT // 2 - target_rect.centery
        
        # Clamp digicam to degree boundaries
        self.offset_x = min(0, max(-(self.width - SCREEN_WIDTH), self.offset_x))
        self.offset_y = min(0, max(-(self.top - SCREEN_HEIGHT), self.offset_y))
        
        # Replace viewport for different calculations
        self.viewport = pygame.Rect(-self.offset_x, -self.offset_y, self.width, self.top)

# Load TMX map
strive:
    tmx_map = pytmx.load_pygame("sonic take a look at world.tmx")
    # Calculate degree dimensions based mostly on map properties
    level_width = tmx_map.width * 64  # Assuming 64px tiles
    level_height = tmx_map.top * 64
besides Exception as e:
    print(f"Error loading TMX map: {e}")
    # Fallback to a easy degree dimension
    level_width = 2000
    level_height = 1000
    tmx_map = None

# Participant class
class Participant:
    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.pace = 5
        self.gravity = 0.5
        self.grounded = False
        self.jump_power = -10
        self.coloration = (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 = Masks.newSensor(left_rect, (self.sensor_thickness // 2, 0))
        self.right_sensor = Masks.newSensor(right_rect, (self.sensor_thickness // 2, 0))
    
    def replace(self):
        # Deal with motion
        keys = pygame.key.get_pressed()
        self.x_vel = 0
        if keys[pygame.K_LEFT]: self.x_vel = -self.pace
        if keys[pygame.K_RIGHT]: self.x_vel = self.pace
        
        # Deal with leaping
        if keys[pygame.K_SPACE] and self.grounded:
            self.y_vel = self.jump_power
            self.grounded = False
        
        # Apply gravity if not on floor
        if not self.grounded:
            self.y_vel += self.gravity
            # Restrict fall pace
            self.y_vel = min(self.y_vel, 10)
        
        # Replace place
        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 - can be set to True once more if collision detected
        self.grounded = False
    
    def draw(self, floor, digicam):
        # Draw participant with digicam offset
        pygame.draw.rect(floor, self.coloration, digicam.apply(self.rect))
        
        # Draw sensors (for debugging)
        pygame.draw.rect(floor, (0, 0, 255), digicam.apply(self.left_sensor[1]))
        pygame.draw.rect(floor, (0, 0, 255), digicam.apply(self.right_sensor[1]))

# Create tiles checklist
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.Floor):
                    tile_image = tile_gid
                else:
                    # Retrieve the tile picture by GID if it isn't already a floor
                    tile_image = tmx_map.get_tile_image_by_gid(tile_gid)
                if tile_image:
                    scaled_image = pygame.remodel.scale(tile_image, (64, 64))
                    tile_rect = pygame.Rect(tile_x, tile_y, 64, 64)
                    tiles.append((scaled_image, tile_rect, pygame.masks.from_surface(scaled_image)))

# Create participant
participant = Participant(100, 100)

# Create digicam
digicam = Digicam(max(level_width, 2000), max(level_height, 1000))

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

# Primary sport loop
working = True
ground_correction_factor = 0.5
max_ground_correction = 10.0

whereas working:
    # Deal with occasions
    for occasion in pygame.occasion.get():
        if occasion.sort == pygame.QUIT or (occasion.sort == pygame.KEYDOWN and occasion.key == pygame.K_ESCAPE):
            working = False
    
    # Replace participant
    participant.replace()
    
    # Deal with collisions
    ground_corrections = []
    for _, tile_rect, tile_mask in tiles:
        # Solely verify tiles which might be close to the participant (optimization)
        if abs(tile_rect.x - participant.rect.x) < SCREEN_WIDTH and abs(tile_rect.y - participant.rect.y) < SCREEN_HEIGHT:
            # Create tile masks object
            tile_mask_obj = Masks.surface_to_mask(pygame.Floor((tile_rect.width, tile_rect.top)), 
                                               (tile_rect.x, tile_rect.y))
            tile_mask_obj[0] = tile_mask  # Set the proper masks
            
            # Test floor collision with each sensors
            for sensor in [player.left_sensor, player.right_sensor]:
                if Masks.collide(sensor, tile_mask_obj):
                    y_correction = Masks.collide_inside_y_minus(sensor, tile_mask_obj)
                    if y_correction < 0:
                        ground_corrections.append(-y_correction)
                        participant.grounded = True
    
    # Apply floor correction
    if ground_corrections:
        avg_correction = sum(ground_corrections) / len(ground_corrections)
        smooth_correction = min(avg_correction * ground_correction_factor, max_ground_correction)
        participant.y -= smooth_correction
        participant.rect.y = int(participant.y)
        participant.y_vel = 0  # Reset vertical velocity when touchdown
    
    # Replace digicam to observe participant
    digicam.replace(participant.rect)
    
    # Draw every part
    display screen.fill((200, 230, 255))  # Sky blue background
    
    # Draw solely tiles which might be seen on display screen
    for tile_img, tile_rect, _ in tiles:
        # Test if the tile is within the viewport earlier than drawing
        if digicam.viewport.colliderect(tile_rect):
            display screen.blit(tile_img, digicam.apply(tile_rect))
    
    # Draw participant
    participant.draw(display screen, digicam)
    
    # Draw FPS
    fps_text = font.render(f"FPS: {int(clock.get_fps())}", True, (0, 0, 0))
    display screen.blit(fps_text, (10, 10))
    
    # Draw coordinates
    coord_text = font.render(f"X: {int(participant.x)}, Y: {int(participant.y)}", True, (0, 0, 0))
    display screen.blit(coord_text, (10, 30))
    
    pygame.show.flip()
    clock.tick(60)

pygame.give up()

What I anticipate to occur is that Sonic ought to be capable of detect the bottom utilizing the sensors, primarily snap to the masks of the tile and from there, act like a traditional platformer. If the sensors detect a change in floor degree (i.e the sensors detect a pixel enhance in top) then Sonic ought to transfer up/down to permit the sensors to keep up a correspondence with the masks. A great instance of how this could look is beneath:

footage of how sensor system works in Sega Genesis Sonic games

What occurs as a substitute is a jittery mess of a collision system. Technically, the collisions are being detected and do work as Sonic for a quick second is ready to snap to the proper positions of the tile masks. The issue largely lies within the calculations carried out AFTER the collision which is the place Sonic’s place is up to date:

https://imgur.com/zFPyDUF

I obtained AI to assist me implement this into the venture, which sure, I perceive shouldn’t be the neatest thought once you your self have completely no clue on easy methods to even start to grasp the logic right here. That is why I am posting this situation right here, so that somebody might be able to assist.

EDIT: The creator of the Masks class has a Sonic Pygame venture of their very own which utilises it fantastically:
https://youtu.be/OFjInMdYlB4

I’m not certain if the above video helps in dissecting the Masks class and easy methods to higher implement it, however it’s there for reference.



Supply hyperlink

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments