I’m making a Sonic recreation 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 how you can truly utilise it and my implementation doesn’t work appropriately with Sonic jittering throughout the map (possible 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 = listing[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):
operating = True
LOOP = 0
whereas operating:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1]-LOOP)]):
LOOP += 1
else: operating = False
if LOOP >= LOOPMAX:
operating = False
return LOOP
def collide_outside_y(sensor1, sensor2):
operating = True
LOOP = 0
whereas operating:
if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1] + LOOP)]):
LOOP += 1
else:operating = False
if LOOP >= LOOPMAX: operating = False
return LOOP
def collide_inside_x(sensor1, sensor2):
operating = True
LOOP = 0
whereas operating:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]-LOOP), sensor2[1][1] - (sensor1[1][1])]):
LOOP += 1
else: operating = False
if LOOP >= LOOPMAX:operating = False
return LOOP
def collide_outside_x(sensor1, sensor2):
operating = True
LOOP = 0
whereas operating:
if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]+ LOOP), sensor2[1][1] - (sensor1[1][1])]):
LOOP += 1
else:operating = False
if LOOP >= LOOPMAX: operating = False
return LOOP
def collide_inside_y_minus(sensor1, sensor2):
operating = True
LOOP = 0
whereas operating:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1]+LOOP)]):
LOOP += 1
else: operating = False
if LOOP >= LOOPMAX:operating = False
return -LOOP
def collide_outside_y_minus(sensor1, sensor2):
operating = True
LOOP = 0
whereas operating:
if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1] - LOOP)]):
LOOP += 1
else:operating = False
if LOOP >= LOOPMAX: operating = False
return -LOOP
def collide_inside_x_minus(sensor1, sensor2):
operating = True
LOOP = 0
whereas operating:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]+LOOP), sensor2[1][1] - (sensor1[1][1])]):
LOOP += 1
else: operating = False
if LOOP >= LOOPMAX:operating = False
return -LOOP
def collide_outside_x_minus(sensor1, sensor2):
operating = True
LOOP = 0
whereas operating:
if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]- LOOP), sensor2[1][1] - (sensor1[1][1])]):
LOOP += 1
else:operating = False
if LOOP >= LOOPMAX: operating = 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 = 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 = listing[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, peak):
self.viewport = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
self.width = width
self.peak = peak
self.offset_x = 0
self.offset_y = 0
def apply(self, entity_rect):
"""Apply digital camera offset to entity rect"""
return entity_rect.transfer(self.offset_x, self.offset_y)
def replace(self, target_rect):
"""Replace digital camera place to observe the goal"""
# Heart the digital camera on the goal
self.offset_x = SCREEN_WIDTH // 2 - target_rect.centerx
self.offset_y = SCREEN_HEIGHT // 2 - target_rect.centery
# Clamp digital camera to degree boundaries
self.offset_x = min(0, max(-(self.width - SCREEN_WIDTH), self.offset_x))
self.offset_y = min(0, max(-(self.peak - SCREEN_HEIGHT), self.offset_y))
# Replace viewport for different calculations
self.viewport = pygame.Rect(-self.offset_x, -self.offset_y, self.width, self.peak)
# Load TMX map
attempt:
tmx_map = pytmx.load_pygame("sonic take a look at world.tmx")
# Calculate degree dimensions primarily based on map properties
level_width = tmx_map.width * 64 # Assuming 64px tiles
level_height = tmx_map.peak * 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 - might be set to True once more if collision detected
self.grounded = False
def draw(self, floor, digital camera):
# Draw participant with digital camera offset
pygame.draw.rect(floor, self.coloration, digital camera.apply(self.rect))
# Draw sensors (for debugging)
pygame.draw.rect(floor, (0, 0, 255), digital camera.apply(self.left_sensor[1]))
pygame.draw.rect(floor, (0, 0, 255), digital camera.apply(self.right_sensor[1]))
# Create tiles listing
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 is not already a floor
tile_image = tmx_map.get_tile_image_by_gid(tile_gid)
if tile_image:
scaled_image = pygame.rework.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 digital camera
digital camera = Digicam(max(level_width, 2000), max(level_height, 1000))
# FPS show
font = pygame.font.SysFont(None, 24)
# Major recreation loop
operating = True
ground_correction_factor = 0.5
max_ground_correction = 10.0
whereas operating:
# 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):
operating = False
# Replace participant
participant.replace()
# Deal with collisions
ground_corrections = []
for _, tile_rect, tile_mask in tiles:
# Solely test tiles which are 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.peak)),
(tile_rect.x, tile_rect.y))
tile_mask_obj[0] = tile_mask # Set the proper masks
# Verify 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 digital camera to observe participant
digital camera.replace(participant.rect)
# Draw every little thing
display.fill((200, 230, 255)) # Sky blue background
# Draw solely tiles which are seen on display
for tile_img, tile_rect, _ in tiles:
# Verify if the tile is within the viewport earlier than drawing
if digital camera.viewport.colliderect(tile_rect):
display.blit(tile_img, digital camera.apply(tile_rect))
# Draw participant
participant.draw(display, digital camera)
# Draw FPS
fps_text = font.render(f"FPS: {int(clock.get_fps())}", True, (0, 0, 0))
display.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.blit(coord_text, (10, 30))
pygame.show.flip()
clock.tick(60)
pygame.give up()
What I count on to occur is that Sonic ought to be capable of detect the bottom utilizing the sensors, basically 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 improve in peak) then Sonic ought to transfer up/down to permit the sensors to keep up a correspondence with the masks. A superb instance of how this could look is beneath:
What occurs as an alternative 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 principally lies within the calculations accomplished AFTER the collision which is the place Sonic’s place is up to date:
I received AI to assist me implement this into the mission, which sure, I perceive isn’t the neatest thought if you your self have completely no clue on how you can 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 mission 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 how you can higher implement it, however it’s there for reference.