0
\$\begingroup\$

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 caused 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 tile set image (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:

footage of how sensor system works in Sega Genesis Sonic games

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.

\$\endgroup\$

0

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.