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:
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:
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.